diff --git a/.travis.yml b/.travis.yml index 1505b174..ca4305ea 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,3 +9,7 @@ rvm: - 2.4 - 2.5 - 2.6 + - jruby-9.2.8.0 +matrix: + allow_failures: + - rvm: jruby-9.2.8.0 diff --git a/Gemfile b/Gemfile index b79dd94e..be173b20 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,4 @@ source "https://rubygems.org" -ruby File.read(".ruby-version").chomp - gemspec diff --git a/ferrum.gemspec b/ferrum.gemspec index 7914f8eb..cbb45783 100644 --- a/ferrum.gemspec +++ b/ferrum.gemspec @@ -27,8 +27,11 @@ Gem::Specification.new do |s| s.add_development_dependency "rspec", "~> 3.8" s.add_development_dependency "sinatra", "~> 2.0" s.add_development_dependency "puma", "~> 4.1" - s.add_development_dependency "byebug", "~> 10.0" s.add_development_dependency "image_size", "~> 2.0" s.add_development_dependency "pdf-reader", "~> 2.2" s.add_development_dependency "chunky_png", "~> 1.3" + + if RUBY_PLATFORM !~ /java/ + s.add_development_dependency "byebug", "~> 10.0" + end end diff --git a/lib/ferrum.rb b/lib/ferrum.rb index e7c35d80..5473049b 100644 --- a/lib/ferrum.rb +++ b/lib/ferrum.rb @@ -90,6 +90,22 @@ 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 diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index e252edbc..1443430e 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -9,7 +9,7 @@ module Ferrum class Browser - TIMEOUT = 5 + DEFAULT_TIMEOUT = ENV.fetch("FERRUM_DEFAULT_TIMEOUT", 5).to_i WINDOW_SIZE = [1024, 768].freeze BASE_URL_SCHEMA = %w[http https].freeze @@ -75,7 +75,7 @@ def extensions end def timeout - @timeout || TIMEOUT + @timeout || DEFAULT_TIMEOUT end def command(*args) @@ -121,6 +121,7 @@ def crash private def start + Ferrum.started @process = Process.start(@options) @client = Client.new(self, @process.ws_url, 0, false) end diff --git a/lib/ferrum/browser/client.rb b/lib/ferrum/browser/client.rb index 1c0f255d..7447b441 100644 --- a/lib/ferrum/browser/client.rb +++ b/lib/ferrum/browser/client.rb @@ -53,9 +53,7 @@ def close @ws.close # Give a thread some time to handle a tail of messages @pendings.clear - Timeout.timeout(1) { @thread.join } - rescue Timeout::Error - @thread.kill + @thread.kill unless @thread.join(1) end private diff --git a/lib/ferrum/browser/process.rb b/lib/ferrum/browser/process.rb index c908849b..445241b5 100644 --- a/lib/ferrum/browser/process.rb +++ b/lib/ferrum/browser/process.rb @@ -10,7 +10,8 @@ module Ferrum class Browser class Process KILL_TIMEOUT = 2 - PROCESS_TIMEOUT = 2 + WAIT_KILLED = 0.05 + PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 2).to_i BROWSER_PATH = ENV["BROWSER_PATH"] BROWSER_HOST = "127.0.0.1" BROWSER_PORT = "0" @@ -69,10 +70,10 @@ def self.process_killer(pid) ::Process.kill("KILL", pid) else ::Process.kill("USR1", pid) - start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + start = Ferrum.monotonic_time while ::Process.wait(pid, ::Process::WNOHANG).nil? - sleep 0.05 - next unless (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) > KILL_TIMEOUT + sleep(WAIT_KILLED) + next unless Ferrum.timeout?(start, KILL_TIMEOUT) ::Process.kill("KILL", pid) ::Process.wait(pid) break @@ -142,17 +143,13 @@ def start read_io, write_io = IO.pipe process_options = { in: File::NULL } process_options[:pgroup] = true unless Ferrum.windows? - if Ferrum.mri? - process_options[:out] = process_options[:err] = write_io - end + process_options[:out] = process_options[:err] = write_io raise Cliver::Dependency::NotFound.new(NOT_FOUND) unless @path - redirect_stdout(write_io) do - @cmd = [@path] + @options.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" } - @pid = ::Process.spawn(*@cmd, process_options) - ObjectSpace.define_finalizer(self, self.class.process_killer(@pid)) - end + @cmd = [@path] + @options.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" } + @pid = ::Process.spawn(*@cmd, process_options) + ObjectSpace.define_finalizer(self, self.class.process_killer(@pid)) parse_ws_url(read_io, @process_timeout) ensure @@ -173,34 +170,17 @@ def restart private - def redirect_stdout(write_io) - if Ferrum.mri? - yield - else - begin - prev = STDOUT.dup - $stdout = write_io - STDOUT.reopen(write_io) - yield - ensure - STDOUT.reopen(prev) - $stdout = STDOUT - prev.close - end - end - end - def kill self.class.process_killer(@pid).call @pid = nil end - def parse_ws_url(read_io, timeout = PROCESS_TIMEOUT) + def parse_ws_url(read_io, timeout) output = "" - start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + start = Ferrum.monotonic_time max_time = start + timeout regexp = /DevTools listening on (ws:\/\/.*)/ - while (now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)) < max_time + while (now = Ferrum.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 13769abb..b2a7a214 100644 --- a/lib/ferrum/browser/web_socket.rb +++ b/lib/ferrum/browser/web_socket.rb @@ -36,8 +36,6 @@ def initialize(url, logger) end end - @thread.priority = 1 - @driver.start end @@ -49,7 +47,7 @@ def on_open(_event) def on_message(event) data = JSON.parse(event.data) @messages.push(data) - @logger&.puts(" ◀ #{event.data}\n") + @logger&.puts(" ◀ #{Ferrum.elapsed_time} #{event.data}\n") end def on_close(_event) @@ -60,7 +58,7 @@ def on_close(_event) def send_message(data) json = data.to_json @driver.text(json) - @logger&.puts("\n\n▶ #{json}") + @logger&.puts("\n\n▶ #{Ferrum.elapsed_time} #{json}") end def write(data) diff --git a/lib/ferrum/mouse.rb b/lib/ferrum/mouse.rb index 276721c0..20df04c4 100644 --- a/lib/ferrum/mouse.rb +++ b/lib/ferrum/mouse.rb @@ -2,6 +2,7 @@ module Ferrum class Mouse + CLICK_WAIT = ENV.fetch("FERRUM_CLICK_WAIT", 0.05).to_f VALID_BUTTONS = %w[none left middle right back forward].freeze def initialize(page) @@ -13,12 +14,13 @@ def scroll_to(top, left) tap { @page.execute("window.scrollTo(#{top}, #{left})") } end - def click(x:, y:, delay: 0, timeout: 0, **options) + def click(x:, y:, delay: 0, wait: CLICK_WAIT, **options) move(x: x, y: y) down(**options) sleep(delay) - # Potential wait because if network event is triggered then we have to wait until it's over. - up(timeout: timeout, **options) + # 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) self end @@ -39,11 +41,11 @@ def move(x:, y:, steps: 1) private - def mouse_event(type:, button: :left, count: 1, modifiers: nil, timeout: 0) + def mouse_event(type:, button: :left, count: 1, modifiers: nil, wait: 0) button = validate_button(button) options = { x: @x, y: @y, type: type, button: button, clickCount: count } options.merge!(modifiers: modifiers) if modifiers - @page.command("Input.dispatchMouseEvent", timeout: timeout, **options) + @page.command("Input.dispatchMouseEvent", wait: wait, **options) end def validate_button(button) diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb index 0ce858c3..0eb8bf0d 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -2,8 +2,6 @@ module Ferrum class Node - CLICK_WAIT = ENV.fetch("FERRUM_CLICK_WAIT", 0.05).to_f - attr_reader :page, :target_id, :node_id, :description, :tag_name def initialize(page, target_id, node_id, description) @@ -45,7 +43,7 @@ def click(mode: :left, keys: [], offset: {}) page.mouse.down(modifiers: modifiers, count: 2) page.mouse.up(modifiers: modifiers, count: 2) when :left - page.mouse.click(x: x, y: y, modifiers: modifiers, timeout: CLICK_WAIT) + page.mouse.click(x: x, y: y, modifiers: modifiers) end self diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index aa1f5cec..467f1573 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -34,7 +34,8 @@ # details (DOM.describeNode). module Ferrum class Page - NEW_WINDOW_BUG_SLEEP = 0.3 + MODAL_WAIT = ENV.fetch("FERRUM_MODAL_WAIT", 0.05).to_f + NEW_WINDOW_WAIT = ENV.fetch("FERRUM_NEW_WINDOW_WAIT", 0.3).to_f class Event < Concurrent::Event def iteration @@ -45,7 +46,7 @@ def reset synchronize do @iteration += 1 @set = false if @set - true + @iteration end end end @@ -70,7 +71,7 @@ def initialize(target_id, browser, new_window = false) @modal_messages = [] # Dirty hack because new window doesn't have events at all - sleep(NEW_WINDOW_BUG_SLEEP) if new_window + sleep(NEW_WINDOW_WAIT) if new_window @session_id = @browser.command("Target.attachToTarget", targetId: @target_id)["sessionId"] @@ -93,7 +94,7 @@ def timeout def goto(url = nil) options = { url: combine_url!(url) } options.merge!(referrer: referrer) if referrer - response = command("Page.navigate", timeout: timeout, **options) + response = command("Page.navigate", wait: timeout, **options) # https://cs.chromium.org/chromium/src/net/base/net_error_list.h if %w[net::ERR_NAME_NOT_RESOLVED net::ERR_NAME_RESOLUTION_FAILED @@ -129,7 +130,7 @@ def resize(width: nil, height: nil, fullscreen: false) end def refresh - command("Page.reload", timeout: timeout) + command("Page.reload", wait: timeout) end def network_traffic(type = nil) @@ -173,9 +174,9 @@ def dismiss_prompt end def find_modal(options) - start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - timeout_sec = options.fetch(:wait) { session_wait_time } - expect_text = options[:text] + start = Ferrum.monotonic_time + timeout = options.fetch(:wait) { session_wait_time } + expect_text = options[:text] expect_regexp = expect_text.is_a?(Regexp) ? expect_text : Regexp.escape(expect_text.to_s) not_found_msg = "Unable to find modal dialog" not_found_msg += " with #{expect_text}" if expect_text @@ -184,8 +185,8 @@ def find_modal(options) modal_text = @modal_messages.shift raise ModalNotFoundError if modal_text.nil? || (expect_text && !modal_text.match(expect_regexp)) rescue ModalNotFoundError => e - raise e, not_found_msg if (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time) >= timeout_sec - sleep(0.05) + raise e, not_found_msg if Ferrum.timeout?(start, timeout) + sleep(MODAL_WAIT) retry end @@ -198,12 +199,13 @@ def reset_modals @modal_messages = [] end - def command(method, timeout: 0, **params) - @event.reset if timeout > 0 - iteration = @event.iteration + def command(method, wait: 0, **params) + iteration = @event.reset if wait > 0 result = @client.command(method, params) - @event.wait(timeout) if timeout > 0 - @event.wait(@browser.timeout) if iteration != @event.iteration + if wait > 0 + @event.wait(wait) + @event.wait(@browser.timeout) if iteration != @event.iteration + end result end @@ -220,6 +222,7 @@ def subscribe if @browser.js_errors @client.on("Runtime.exceptionThrown") do |params| + # FIXME https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/ Thread.main.raise JavaScriptError.new(params.dig("exceptionDetails", "exception")) end end @@ -363,7 +366,7 @@ def history_navigate(delta:) if entry = entries[index + delta] # Potential wait because of network event - command("Page.navigateToHistoryEntry", timeout: 0.05, entryId: entry["id"]) + command("Page.navigateToHistoryEntry", wait: Mouse::CLICK_WAIT, entryId: entry["id"]) end end diff --git a/lib/ferrum/page/runtime.rb b/lib/ferrum/page/runtime.rb index 788790e1..a708642a 100644 --- a/lib/ferrum/page/runtime.rb +++ b/lib/ferrum/page/runtime.rb @@ -3,6 +3,9 @@ module Ferrum class Page module Runtime + INTERMITTENT_ATTEMPTS = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i + INTERMITTENT_SLEEP = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f + EXECUTE_OPTIONS = { returnByValue: true, functionDeclaration: %(function() { %s }) @@ -41,12 +44,11 @@ def execute(expression, *args) true end - def evaluate_on(node:, expression:, by_value: true, timeout: 0) + def evaluate_on(node:, expression:, by_value: true, wait: 0) errors = [NodeNotFoundError, NoExecutionContextError] - max = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i - wait = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f + attempts, sleep = INTERMITTENT_ATTEMPTS, INTERMITTENT_SLEEP - Ferrum.with_attempts(errors: errors, max: max, wait: wait) do + Ferrum.with_attempts(errors: errors, max: attempts, wait: sleep) do response = command("DOM.resolveNode", nodeId: node.node_id) object_id = response.dig("object", "objectId") options = DEFAULT_OPTIONS.merge(objectId: object_id) @@ -54,8 +56,8 @@ def evaluate_on(node:, expression:, by_value: true, timeout: 0) options.merge!(returnByValue: by_value) response = command("Runtime.callFunctionOn", - timeout: timeout, - **options)["result"].tap { |r| handle_error(r) } + wait: wait, **options)["result"] + .tap { |r| handle_error(r) } by_value ? response.dig("value") : handle_response(response) end @@ -65,10 +67,9 @@ def evaluate_on(node:, expression:, by_value: true, timeout: 0) def call(*args, expression:, wait_time: nil, handle: true, **options) errors = [NodeNotFoundError, NoExecutionContextError] - max = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i - wait = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f + attempts, sleep = INTERMITTENT_ATTEMPTS, INTERMITTENT_SLEEP - Ferrum.with_attempts(errors: errors, max: max, wait: wait) do + Ferrum.with_attempts(errors: errors, max: attempts, wait: sleep) do arguments = prepare_args(args) params = DEFAULT_OPTIONS.merge(options) expression = [wait_time, expression] if wait_time diff --git a/spec/session_spec.rb b/spec/session_spec.rb index d210d556..9d6beaea 100644 --- a/spec/session_spec.rb +++ b/spec/session_spec.rb @@ -10,7 +10,6 @@ module Ferrum node = browser.at_css("#remove_me") expect(node.text).to eq("Remove me") browser.at_css("#remove").click - sleep 1 expect { node.text }.to raise_error(Ferrum::NodeNotFoundError) end @@ -18,12 +17,14 @@ module Ferrum browser.goto("/ferrum/index") node = browser.at_xpath(".//a") browser.execute "window.location = 'about:blank'" - # expect { node.text }.to raise_error(Ferrum::ObsoleteNode) + expect { node.text }.to raise_error(Ferrum::NodeNotFoundError) end it "raises an error if the element is not visible" do browser.goto("/ferrum/index") - browser.execute %(document.querySelector("a[href=js_redirect]").style.display = "none") + browser.execute <<~JS + document.querySelector("a[href=js_redirect]").style.display = "none" + JS expect { browser.at_xpath("//a[text()='JS redirect']").click }.to raise_error(Ferrum::BrowserError, "Could not compute content quads.") end diff --git a/spec/support/server.rb b/spec/support/server.rb index ae0841f3..291f0a04 100644 --- a/spec/support/server.rb +++ b/spec/support/server.rb @@ -71,9 +71,9 @@ def base_url(path = nil) end def wait_for_pending_requests - start = clock_gettime + start = Ferrum.monotonic_time while pending_requests? - if expired?(start, KILL_TIMEOUT) + if Ferrum.timeout?(start, KILL_TIMEOUT) raise "Requests did not finish in #{KILL_TIMEOUT} seconds" end @@ -83,11 +83,11 @@ def wait_for_pending_requests def boot! unless responsive? - start = clock_gettime + start = Ferrum.monotonic_time @server_thread = Thread.new { run } until responsive? - if expired?(start, KILL_TIMEOUT) + if Ferrum.timeout?(start, KILL_TIMEOUT) raise "Rack application timed out during boot" end @@ -127,14 +127,6 @@ def responsive? false end - def clock_gettime - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - end - - def expired?(start, timeout) - (clock_gettime - start) > timeout - end - def pending_requests? middleware.pending_requests? end