diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..465cb51f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.byebug_history +Gemfile.* +pkg diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..ec1cf33c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.6.3 diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..b79dd94e --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +ruby File.read(".ruby-version").chomp + +gemspec diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f0b5518f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Dmitry Vorotilin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..1f4d7d45 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Ferrum - fearless Ruby headless Chrome driver (as simple as Puppeteer, though even simpler). + +Navigate to `example.com` and save a screenshot: + +```ruby +browser = Ferrum::Browser.new +browser.goto("https://example.com") +browser.screenshot(path: "example.png") +browser.quit +``` + +## Links +https://medium.com/@aslushnikov/automating-clicks-in-chromium-a50e7f01d3fb +https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API +https://github.com/machinio/cuprite/commit/9b1041dd6cd954e0b40b17bc74824e7a3a3ff3f4 diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000..3d8900f6 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new("test") + +task default: :test diff --git a/bin/console b/bin/console new file mode 100755 index 00000000..d6bd0255 --- /dev/null +++ b/bin/console @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +require "irb" +require "irb/completion" +require "ferrum" + +IRB.start diff --git a/ferrum.gemspec b/ferrum.gemspec new file mode 100644 index 00000000..3f75466c --- /dev/null +++ b/ferrum.gemspec @@ -0,0 +1,33 @@ +lib = File.expand_path("lib", __dir__) +$:.unshift(lib) unless $:.include?(lib) + +require "ferrum/version" + +Gem::Specification.new do |s| + s.name = "ferrum" + s.version = Ferrum::VERSION + s.platform = Gem::Platform::RUBY + s.authors = ["Dmitry Vorotilin"] + s.email = ["d.vorotilin@gmail.com"] + s.homepage = "https://github.com/route/ferrum" + s.summary = "Ruby headless Chrome driver" + s.description = "Ferrum allows you to control headless Chrome browser" + s.license = "MIT" + s.require_paths = ["lib"] + s.files = Dir["{lib}/**/*"] + %w[LICENSE README.md] + + s.required_ruby_version = ">= 2.3.0" + + s.add_runtime_dependency "websocket-driver", ">= 0.6", "< 0.8" + s.add_runtime_dependency "cliver", "~> 0.3" + s.add_runtime_dependency "addressable", "~> 2.6" + + s.add_development_dependency "rake", "~> 12.3" + 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" +end diff --git a/lib/ferrum.rb b/lib/ferrum.rb new file mode 100644 index 00000000..20b7066e --- /dev/null +++ b/lib/ferrum.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +Thread.abort_on_exception = true +Thread.report_on_exception = true if Thread.respond_to?(:report_on_exception=) + +module Ferrum + require "ferrum/browser" + require "ferrum/node" + require "ferrum/errors" + require "ferrum/cookie" + + 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 + end +end diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb new file mode 100644 index 00000000..1312a8e2 --- /dev/null +++ b/lib/ferrum/browser.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "base64" +require "forwardable" +require "ferrum/page" +require "ferrum/targets" +require "ferrum/browser/api" +require "ferrum/browser/process" +require "ferrum/browser/client" + +module Ferrum + class Browser + TIMEOUT = 5 + WINDOW_SIZE = [1024, 768].freeze + BASE_URL_SCHEMA = %w[http https].freeze + + include API + extend Forwardable + + attr_reader :headers, :window_size + + delegate on: :@client + delegate %i(window_handle window_handles switch_to_window open_new_window + close_window within_window page) => :targets + delegate %i(goto status body at_css at_xpath css xpath text property attributes attribute select_file + value visible? disabled? network_traffic clear_network_traffic + path response_headers refresh click right_click double_click + hover set click_coordinates select trigger scroll_to send_keys + evaluate evaluate_on evaluate_async execute frame_url + frame_title switch_to_frame current_url title go_back + go_forward find_modal accept_confirm dismiss_confirm + accept_prompt dismiss_prompt reset_modals authorize + proxy_authorize) => :page + + attr_reader :process, :logger, :js_errors, :slowmo, :base_url, + :url_blacklist, :url_whitelist, :options + attr_writer :timeout + + def initialize(options = nil) + options ||= {} + + @client = nil + @window_size = options.fetch(:window_size, WINDOW_SIZE) + @original_window_size = @window_size + + @options = Hash(options.merge(window_size: @window_size)) + @logger, @timeout = @options.values_at(:logger, :timeout) + @js_errors = @options.fetch(:js_errors, false) + @slowmo = @options[:slowmo] + + if @options.key?(:base_url) + self.base_url = @options[:base_url] + end + + self.url_blacklist = @options[:url_blacklist] + self.url_whitelist = @options[:url_whitelist] + + if ENV["FERRUM_DEBUG"] && !@logger + STDOUT.sync = true + @logger = STDOUT + @options[:logger] = @logger + end + + @options.freeze + + start + end + + def base_url=(value) + parsed = Addressable::URI.parse(value) + unless BASE_URL_SCHEMA.include?(parsed.normalized_scheme) + raise "Set `base_url` should be absolute and include schema: #{BASE_URL_SCHEMA}" + end + + @base_url = parsed + end + + def extensions + @extensions ||= Array(@options[:extensions]).map { |p| File.read(p) } + end + + def timeout + @timeout || TIMEOUT + end + + def command(*args) + id = @client.command(*args) + @client.wait(id: id) + rescue DeadBrowser + restart + raise + end + + def set_overrides(user_agent: nil, accept_language: nil, platform: nil) + options = Hash.new + options[:userAgent] = user_agent if user_agent + options[:acceptLanguage] = accept_language if accept_language + options[:platform] if platform + + page.command("Network.setUserAgentOverride", **options) if !options.empty? + end + + def clear_memory_cache + page.command("Network.clearBrowserCache") + end + + def reset + @headers = {} + @zoom_factor = nil + @window_size = @original_window_size + targets.reset + end + + def restart + quit + start + end + + def quit + @client.close + @process.stop + @client = @process = @targets = nil + end + + def targets + @targets ||= Targets.new(self) + end + + def resize(**options) + @window_size = [options[:width], options[:height]] + page.resize(**options) + end + + def crash + command("Browser.crash") + end + + private + + def start + @headers = {} + @process = Process.start(@options) + @client = Client.new(self, @process.ws_url, 0, false) + end + end +end diff --git a/lib/ferrum/browser/api.rb b/lib/ferrum/browser/api.rb new file mode 100644 index 00000000..a229251f --- /dev/null +++ b/lib/ferrum/browser/api.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "ferrum/browser/api/cookie" +require "ferrum/browser/api/header" +require "ferrum/browser/api/screenshot" +require "ferrum/browser/api/intercept" + +module Ferrum + class Browser + module API + include Cookie, Header, Screenshot, Intercept + end + end +end diff --git a/lib/ferrum/browser/api/cookie.rb b/lib/ferrum/browser/api/cookie.rb new file mode 100644 index 00000000..cd00b207 --- /dev/null +++ b/lib/ferrum/browser/api/cookie.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Ferrum + class Browser + module API + module Cookie + def cookies + cookies = page.command("Network.getAllCookies")["cookies"] + cookies.map { |c| [c["name"], ::Ferrum::Cookie.new(c)] }.to_h + end + + def set_cookie(name: nil, value: nil, cookie: nil, **options) + cookie = options.dup + cookie[:name] ||= name + cookie[:value] ||= value + cookie[:domain] ||= default_domain + + expires = cookie.delete(:expires).to_i + cookie[:expires] = expires if expires > 0 + + page.command("Network.setCookie", **cookie) + end + + # Supports :url, :domain and :path options + def remove_cookie(name:, **options) + raise "Specify :domain or :url option" if !options[:domain] && !options[:url] && !default_domain + + options = options.merge(name: name) + options[:domain] ||= default_domain + + page.command("Network.deleteCookies", **options) + end + + def clear_cookies + page.command("Network.clearBrowserCookies") + end + + private + + def default_domain + URI.parse(base_url).host if base_url + end + end + end + end +end diff --git a/lib/ferrum/browser/api/header.rb b/lib/ferrum/browser/api/header.rb new file mode 100644 index 00000000..51a207fc --- /dev/null +++ b/lib/ferrum/browser/api/header.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ferrum + class Browser + module API + module Header + def headers=(headers) + @headers = {} + add_headers(headers) + end + + def add_headers(headers, permanent: true) + if headers["Referer"] + page.referrer = headers["Referer"] + headers.delete("Referer") unless permanent + end + + @headers.merge!(headers) + user_agent = @headers["User-Agent"] + accept_language = @headers["Accept-Language"] + + set_overrides(user_agent: user_agent, accept_language: accept_language) + page.command("Network.setExtraHTTPHeaders", headers: @headers) + end + + def add_header(header, permanent: true) + add_headers(header, permanent: permanent) + end + end + end + end +end diff --git a/lib/ferrum/browser/api/intercept.rb b/lib/ferrum/browser/api/intercept.rb new file mode 100644 index 00000000..1c067ec2 --- /dev/null +++ b/lib/ferrum/browser/api/intercept.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ferrum + class Browser + module API + module Intercept + def url_whitelist=(wildcards) + @url_whitelist = prepare_wildcards(wildcards) + page.intercept_request("*") if @client && !@url_whitelist.empty? + end + + def url_blacklist=(wildcards) + @url_blacklist = prepare_wildcards(wildcards) + page.intercept_request("*") if @client && !@url_blacklist.empty? + end + + private + + def prepare_wildcards(wc) + Array(wc).map do |wildcard| + if wildcard.is_a?(Regexp) + wildcard + else + wildcard = wildcard.gsub("*", ".*") + Regexp.new(wildcard, Regexp::IGNORECASE) + end + end + end + end + end + end +end diff --git a/lib/ferrum/browser/api/screenshot.rb b/lib/ferrum/browser/api/screenshot.rb new file mode 100644 index 00000000..61861edc --- /dev/null +++ b/lib/ferrum/browser/api/screenshot.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Ferrum + class Browser + module API + module Screenshot + def screenshot(**opts) + encoding, path, options = screenshot_options(**opts) + + data = if options[:format].to_s == "pdf" + options = {} + options[:paperWidth] = @paper_size[:width].to_f if @paper_size + options[:paperHeight] = @paper_size[:height].to_f if @paper_size + options[:scale] = @zoom_factor if @zoom_factor + page.command("Page.printToPDF", **options) + else + page.command("Page.captureScreenshot", **options) + end.fetch("data") + + return data if encoding == :base64 + + bin = Base64.decode64(data) + File.open(path.to_s, "wb") { |f| f.write(bin) } + end + + def zoom_factor=(value) + @zoom_factor = value.to_f + end + + def paper_size=(value) + @paper_size = value + end + + private + + def screenshot_options(encoding: :base64, format: nil, path: nil, **opts) + options = {} + + encoding = :binary if path + + if encoding == :binary && !path + raise "Not supported option `:path` #{path}. Should be path to file" + end + + format ||= path ? File.extname(path).delete(".") : "png" + format = "jpeg" if format == "jpg" + raise "Not supported options `:format` #{format}. jpeg | png | pdf" if format !~ /jpeg|png|pdf/i + options.merge!(format: format) + + options.merge!(quality: opts[:quality] ? opts[:quality] : 75) if format == "jpeg" + + if !!opts[:full] && opts[:selector] + warn "Ignoring :selector in #screenshot since full: true was given at #{caller(1..1).first}" + end + + if !!opts[:full] + width, height = page.evaluate("[document.documentElement.offsetWidth, document.documentElement.offsetHeight]") + options.merge!(clip: { x: 0, y: 0, width: width, height: height, scale: @zoom_factor || 1.0 }) if width > 0 && height > 0 + elsif opts[:selector] + rect = page.evaluate("document.querySelector('#{opts[:selector]}').getBoundingClientRect()") + options.merge!(clip: { x: rect["x"], y: rect["y"], width: rect["width"], height: rect["height"], scale: @zoom_factor || 1.0 }) + end + + if @zoom_factor + if !options[:clip] + width, height = page.evaluate("[document.documentElement.clientWidth, document.documentElement.clientHeight]") + options[:clip] = { x: 0, y: 0, width: width, height: height } + end + + options[:clip].merge!(scale: @zoom_factor) + end + + [encoding, path, options] + end + end + end + end +end diff --git a/lib/ferrum/browser/client.rb b/lib/ferrum/browser/client.rb new file mode 100644 index 00000000..844c01e0 --- /dev/null +++ b/lib/ferrum/browser/client.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "timeout" +require "ferrum/browser/web_socket" + +module Ferrum + class Browser + class Client + class IdError < RuntimeError; end + + def initialize(browser, ws_url, start_id = 0, allow_slowmo = true) + @command_id = start_id + @on = Hash.new { |h, k| h[k] = [] } + @browser, @allow_slowmo = browser, allow_slowmo + @commands = Queue.new + @ws = WebSocket.new(ws_url, @browser.logger) + + @thread = Thread.new do + while message = @ws.messages.pop + method, params = message.values_at("method", "params") + if method + @on[method].each { |b| b.call(params) } + else + @commands.push(message) + end + end + + @commands.close + end + end + + def command(method, params = {}) + message = build_message(method, params) + sleep(@browser.slowmo) if !@browser.slowmo.nil? && @allow_slowmo + @ws.send_message(message) + message[:id] + end + + def wait(id:) + message = Timeout.timeout(@browser.timeout, TimeoutError) { @commands.pop } + raise DeadBrowser unless message + raise IdError if message["id"] != id + error, response = message.values_at("error", "result") + raise BrowserError.new(error) if error + response + rescue IdError + retry + end + + def on(event, &block) + @on[event] << block + true + end + + def close + @ws.close + # Give a thread some time to handle a tail of messages + Timeout.timeout(1) { @thread.join } + rescue Timeout::Error + @thread.kill + end + + private + + def build_message(method, params) + { method: method, params: params }.merge(id: next_command_id) + end + + def next_command_id + @command_id += 1 + end + end + end +end diff --git a/lib/ferrum/browser/process.rb b/lib/ferrum/browser/process.rb new file mode 100644 index 00000000..3d0a227f --- /dev/null +++ b/lib/ferrum/browser/process.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "cliver" +require "net/http" +require "json" +require "addressable" +require "tmpdir" + +module Ferrum + class Browser + class Process + KILL_TIMEOUT = 2 + PROCESS_TIMEOUT = 1 + BROWSER_PATH = ENV["BROWSER_PATH"] + BROWSER_HOST = "127.0.0.1" + BROWSER_PORT = "0" + DEFAULT_OPTIONS = { + "headless" => nil, + "disable-gpu" => nil, + "hide-scrollbars" => nil, + "mute-audio" => nil, + "enable-automation" => nil, + "disable-web-security" => nil, + "disable-session-crashed-bubble" => nil, + "disable-breakpad" => nil, + "disable-sync" => nil, + "no-first-run" => nil, + "use-mock-keychain" => nil, + "keep-alive-for-test" => nil, + "disable-popup-blocking" => nil, + "disable-extensions" => nil, + "disable-hang-monitor" => nil, + "disable-features" => "site-per-process,TranslateUI", + "disable-translate" => nil, + "disable-background-networking" => nil, + "enable-features" => "NetworkService,NetworkServiceInProcess", + "disable-background-timer-throttling" => nil, + "disable-backgrounding-occluded-windows" => nil, + "disable-client-side-phishing-detection" => nil, + "disable-default-apps" => nil, + "disable-dev-shm-usage" => nil, + "disable-ipc-flooding-protection" => nil, + "disable-prompt-on-repost" => nil, + "disable-renderer-backgrounding" => nil, + "force-color-profile" => "srgb", + "metrics-recording-only" => nil, + "safebrowsing-disable-auto-update" => nil, + "password-store" => "basic", + # Note: --no-sandbox is not needed if you properly setup a user in the container. + # https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40 + # "no-sandbox" => nil, + }.freeze + + NOT_FOUND = "Could not find an executable for chrome. Try to make it " \ + "available on the PATH or set environment varible for " \ + "example BROWSER_PATH=\"/Applications/Chromium.app/Contents/MacOS/Chromium\"" + + + attr_reader :host, :port, :ws_url, :pid, :path, :options, :cmd + + def self.start(*args) + new(*args).tap(&:start) + end + + def self.process_killer(pid) + proc do + begin + if Ferrum.windows? + ::Process.kill("KILL", pid) + else + ::Process.kill("USR1", pid) + start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + while ::Process.wait(pid, ::Process::WNOHANG).nil? + sleep 0.05 + next unless (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) > KILL_TIMEOUT + ::Process.kill("KILL", pid) + ::Process.wait(pid) + break + end + end + rescue Errno::ESRCH, Errno::ECHILD + end + end + end + + def self.detect_browser_path + if RUBY_PLATFORM.include?("darwin") + [ + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + ].find { |path| File.exist?(path) } + else + %w[chromium google-chrome-unstable google-chrome-beta google-chrome chrome chromium-browser google-chrome-stable].reduce(nil) do |path, exe| + path = Cliver.detect(exe) + break path if path + end + end + end + + def initialize(options) + @options = {} + + @path = options[:browser_path] || BROWSER_PATH || self.class.detect_browser_path + + if options[:url] + url = URI.join(options[:url].to_s, "/json/version") + response = JSON.parse(::Net::HTTP.get(url)) + set_ws_url(response["webSocketDebuggerUrl"]) + return + end + + # Doesn't work on MacOS, so we need to set it by CDP as well + @options.merge!("window-size" => options[:window_size].join(",")) + + port = options.fetch(:port, BROWSER_PORT) + @options.merge!("remote-debugging-port" => port) + + host = options.fetch(:host, BROWSER_HOST) + @options.merge!("remote-debugging-address" => host) + + @options.merge!("user-data-dir" => Dir.mktmpdir) + + @options = DEFAULT_OPTIONS.merge(@options) + + unless options.fetch(:headless, true) + @options.delete("headless") + @options.delete("disable-gpu") + end + + @process_timeout = options.fetch(:process_timeout, PROCESS_TIMEOUT) + + @options.merge!(options.fetch(:browser_options, {})) + + @logger = options[:logger] + end + + def start + # Don't do anything as browser is already running as external process. + return if ws_url + + begin + 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 + + 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 + + parse_ws_url(read_io, @process_timeout) + ensure + close_io(read_io, write_io) + end + end + + def stop + return unless @pid + kill + ObjectSpace.undefine_finalizer(self) + end + + def restart + stop + start + end + + 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) + output = "" + start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + max_time = start + timeout + regexp = /DevTools listening on (ws:\/\/.*)/ + while (now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)) < max_time + begin + output += read_io.read_nonblock(512) + rescue IO::WaitReadable + IO.select([read_io], nil, nil, max_time - now) + else + if output.match(regexp) + set_ws_url(output.match(regexp)[1].strip) + break + end + end + end + + unless ws_url + @logger.puts output if @logger + raise "Chrome process did not produce websocket url within #{timeout} seconds" + end + end + + def set_ws_url(url) + @ws_url = Addressable::URI.parse(url) + @host = @ws_url.host + @port = @ws_url.port + end + + def close_io(*ios) + ios.each do |io| + begin + io.close unless io.closed? + rescue IOError + raise unless RUBY_ENGINE == 'jruby' + end + end + end + end + end +end diff --git a/lib/ferrum/browser/web_socket.rb b/lib/ferrum/browser/web_socket.rb new file mode 100644 index 00000000..7c542df3 --- /dev/null +++ b/lib/ferrum/browser/web_socket.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "json" +require "socket" +require "websocket/driver" + +module Ferrum + class Browser + class WebSocket + attr_reader :url, :messages + + def initialize(url, logger) + @url = url + @logger = logger + uri = URI.parse(@url) + @sock = TCPSocket.new(uri.host, uri.port) + @driver = ::WebSocket::Driver.client(self) + @messages = Queue.new + + @driver.on(:open, &method(:on_open)) + @driver.on(:message, &method(:on_message)) + @driver.on(:close, &method(:on_close)) + + @thread = Thread.new do + begin + while data = @sock.readpartial(512) + @driver.parse(data) + end + rescue EOFError, Errno::ECONNRESET + @messages.close + end + end + + @thread.priority = 1 + + @driver.start + end + + def on_open(_event) + sleep 0.01 # https://github.com/faye/websocket-driver-ruby/issues/46 + end + + def on_message(event) + data = JSON.parse(event.data) + @messages.push(data) + @logger&.puts(" ◀ #{event.data}\n") + end + + def on_close(_event) + @messages.close + @thread.kill + end + + def send_message(data) + json = data.to_json + @driver.text(json) + @logger&.puts("\n\n▶ #{json}") + end + + def write(data) + @sock.write(data) + end + + def close + @driver.close + end + end + end +end diff --git a/lib/ferrum/cookie.rb b/lib/ferrum/cookie.rb new file mode 100644 index 00000000..4d99cbc2 --- /dev/null +++ b/lib/ferrum/cookie.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Ferrum + class Cookie + def initialize(attributes) + @attributes = attributes + end + + def name + @attributes["name"] + end + + def value + @attributes["value"] + end + + def domain + @attributes["domain"] + end + + def path + @attributes["path"] + end + + def size + @attributes["size"] + end + + def secure? + @attributes["secure"] + end + + def httponly? + @attributes["httpOnly"] + end + + def session? + @attributes["session"] + end + + def expires + if @attributes["expires"] > 0 + Time.at(@attributes["expires"]) + end + end + end +end diff --git a/lib/ferrum/errors.rb b/lib/ferrum/errors.rb new file mode 100644 index 00000000..d6abf8ba --- /dev/null +++ b/lib/ferrum/errors.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Ferrum + class NotImplemented < StandardError; end + class ModalNotFound < StandardError; end + class Error < StandardError; end + class NoSuchWindowError < Error; end + + class ClientError < Error + attr_reader :response + + def initialize(response) + @response = response + end + end + + class BrowserError < ClientError + def code + response["code"] + end + + def data + response["data"] + end + + def message + response["message"] + end + end + + class JavaScriptError < ClientError + attr_reader :class_name, :message + + def initialize(response) + super + @class_name, @message = response.values_at("className", "description") + end + end + + class StatusFailError < ClientError + def message + "Request to #{response["url"]} failed to reach server, check DNS and/or server status" + end + end + + class FrameNotFound < ClientError + def name + response["args"].first + end + + def message + "The frame "#{name}" was not found." + end + end + + class NodeError < ClientError + attr_reader :node + + def initialize(node, response) + @node = node + super(response) + end + end + + class ObsoleteNode < NodeError + def message + "The element you are trying to interact with is either not part of the DOM, or is " \ + "not currently visible on the page (perhaps display: none is set). " \ + "It is possible the element has been replaced by another element and you meant to interact with " \ + "the new element. If so you need to do a new find in order to get a reference to the " \ + "new element." + 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 DeadBrowser < Error + def initialize(message = "Chrome is dead") + super + end + end +end diff --git a/lib/ferrum/network/error.rb b/lib/ferrum/network/error.rb new file mode 100644 index 00000000..75920be4 --- /dev/null +++ b/lib/ferrum/network/error.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ferrum::Network + class Error + def initialize(data) + @data = data + end + + def id + @data["networkRequestId"] + end + + def url + @data["url"] + end + + def description + @data["text"] + end + + def time + @time ||= Time.strptime(@data["timestamp"].to_s, "%s") + end + end +end diff --git a/lib/ferrum/network/request.rb b/lib/ferrum/network/request.rb new file mode 100644 index 00000000..ed950e4e --- /dev/null +++ b/lib/ferrum/network/request.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "time" + +module Ferrum::Network + class Request + attr_accessor :response, :error + + def initialize(data) + @data = data + end + + def id + @data["id"] + end + + def url + @data["url"] + end + + def method + @data["method"] + end + + def headers + @data["headers"] + end + + def time + @time ||= Time.strptime(@data["time"].to_s, "%s") + end + end +end diff --git a/lib/ferrum/network/response.rb b/lib/ferrum/network/response.rb new file mode 100644 index 00000000..4f0c63c3 --- /dev/null +++ b/lib/ferrum/network/response.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Ferrum::Network + class Response + attr_accessor :body_size + + def initialize(data) + @data = data + end + + def id + @data["id"] + end + + def url + @data["url"] + end + + def status + @data["status"] + end + + def status_text + @data["statusText"] + end + + def headers + @data["headers"] + end + + def headers_size + @data["encodedDataLength"] + end + + # FIXME: didn't check if we have it on redirect response + def redirect_url + @data["redirectURL"] + end + + def content_type + @content_type ||= @data.dig("headers", "contentType").sub(/;.*\z/, "") + end + end +end diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb new file mode 100644 index 00000000..356e6025 --- /dev/null +++ b/lib/ferrum/node.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +module Ferrum + class Node + attr_reader :page, :target_id, :node_id, :desc + + def initialize(page, target_id, node_id, desc) + @page, @target_id, @node_id, @desc = + page, target_id, node_id, desc + end + + def node? + desc["nodeType"] == 1 # nodeType: 3, nodeName: "#text" e.g. + end + + def page_send(name, *args) + page.send(name, self, *args) + rescue BrowserError => e + case e.message + when "No node with given id found" + raise ObsoleteNode.new(self, e.response) + else + raise + end + end + + def at_xpath(selector) + page.at_xpath(selector, within: self) + end + + def at_css(selector) + page.at_css(selector, within: self) + end + + def xpath(selector) + page.xpath(selector, within: self) + end + + def css(selector) + page.css(selector, within: self) + end + + def text + page.evaluate_on(node: self, expression: "this.textContent") + end + + def property(name) + page_send(:property, name) + end + + def [](name) + # Although the attribute matters, the property is consistent. Return that in + # preference to the attribute for links and images. + if ((tag_name == "img") && (name == "src")) || ((tag_name == "a") && (name == "href")) + # if attribute exists get the property + return page_send(:attribute, name) && page_send(:property, name) + end + + value = property(name) + value = page_send(:attribute, name) if value.nil? || value.is_a?(Hash) + + value + end + + def attributes + page_send(:attributes) + end + + def value + page.evaluate_on(node: self, expression: "this.value") + end + + def set(value) + if tag_name == "input" + case self[:type] + when "radio" + click + when "checkbox" + click if value != checked? + when "file" + files = value.respond_to?(:to_ary) ? value.to_ary.map(&:to_s) : value.to_s + page_send(:select_file, files) + else + page_send(:set, value.to_s) + end + elsif tag_name == "textarea" + page_send(:set, value.to_s) + elsif self[:isContentEditable] + # FIXME: + page_send(:delete_text) + send_keys(value.to_s) + end + end + + def select_option + page_send(:select, true) + end + + def unselect_option + raise NotImplemented + end + + def tag_name + @tag_name ||= desc["nodeName"].downcase + end + + def visible? + page_send(:visible?) + end + + def checked? + self[:checked] + end + + def selected? + !!self[:selected] + end + + def disabled? + page_send(:disabled?) + end + + def click(keys = [], offset = {}) + page_send(:click, keys, offset) + end + + def right_click(keys = [], offset = {}) + page_send(:right_click, keys, offset) + end + + def double_click(keys = [], offset = {}) + page_send(:double_click, keys, offset) + end + + def hover + page_send(:hover) + end + + def trigger(event) + page_send(:trigger, event) + end + + def scroll_to(element, location, position = nil) + if element.is_a?(Node) + scroll_element_to_location(element, location) + elsif location.is_a?(Symbol) + scroll_to_location(location) + else + scroll_to_coords(*position) + end + self + end + + def ==(other) + return false unless other.is_a?(Node) + # We compare backendNodeId because once nodeId is sent to frontend backend + # never returns same nodeId sending 0. In other words frontend is + # responsible for keeping track of node ids. + target_id == other.target_id && desc["backendNodeId"] == other.desc["backendNodeId"] + end + + def send_keys(*keys) + page_send(:send_keys, keys) + end + alias_method :send_key, :send_keys + + def path + page_send(:path) + end + + def inspect + %(#<#{self.class} @target_id=#{@target_id.inspect} @node_id=#{@node_id} @desc=#{@desc.inspect}>) + end + end +end diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb new file mode 100644 index 00000000..79f749e9 --- /dev/null +++ b/lib/ferrum/page.rb @@ -0,0 +1,387 @@ +# frozen_string_literal: true + +require "ferrum/page/dom" +require "ferrum/page/input" +require "ferrum/page/runtime" +require "ferrum/page/frame" +require "ferrum/page/net" +require "ferrum/browser/client" +require "ferrum/network/error" +require "ferrum/network/request" +require "ferrum/network/response" + +# RemoteObjectId is from a JavaScript world, and corresponds to any JavaScript +# object, including JS wrappers for DOM nodes. There is a way to convert between +# node ids and remote object ids (DOM.requestNode and DOM.resolveNode). +# +# NodeId is used for inspection, when backend tracks the node and sends updates to +# the frontend. If you somehow got NodeId over protocol, backend should have +# pushed to the frontend all of it's ancestors up to the Document node via +# DOM.setChildNodes. After that, frontend is always kept up-to-date about anything +# happening to the node. +# +# BackendNodeId is just a unique identifier for a node. Obtaining it does not send +# any updates, for example, the node may be destroyed without any notification. +# This is a way to keep a reference to the Node, when you don't necessarily want +# to keep track of it. One example would be linking to the node from performance +# data (e.g. relayout root node). BackendNodeId may be either resolved to +# inspected node (DOM.pushNodesByBackendIdsToFrontend) or described in more +# details (DOM.describeNode). +module Ferrum + class Page + include Input, DOM, Runtime, Frame, Net + + attr_accessor :referrer + attr_reader :target_id, :status, :response_headers + + def initialize(target_id, browser) + @wait = 0 + @target_id, @browser = target_id, browser + @mutex, @resource = Mutex.new, ConditionVariable.new + @network_traffic = [] + + @frames = {} + @waiting_frames ||= Set.new + @frame_stack = [] + @accept_modal = [] + @modal_messages = [] + + begin + @session_id = @browser.command("Target.attachToTarget", targetId: @target_id)["sessionId"] + rescue BrowserError => e + if e.message == "No target with given id found" + raise NoSuchWindowError + else + raise + end + end + + host = @browser.process.host + port = @browser.process.port + ws_url = "ws://#{host}:#{port}/devtools/page/#{@target_id}" + @client = Browser::Client.new(browser, ws_url, 1000) + + on_events + prepare_page + end + + def timeout + @browser.timeout + end + + def goto(url = nil) + @wait = timeout + options = { url: combine_url!(url) } + options.merge!(referrer: referrer) if referrer + response = command("Page.navigate", **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 + net::ERR_INTERNET_DISCONNECTED + net::ERR_CONNECTION_TIMED_OUT].include?(response["errorText"]) + raise StatusFailError, "url" => options[:url] + end + response["frameId"] + end + + def close + @browser.command("Target.detachFromTarget", sessionId: @session_id) + @browser.command("Target.closeTarget", targetId: @target_id) + close_connection + end + + def close_connection + @client.close + end + + def resize(width: nil, height: nil, fullscreen: false) + result = @browser.command("Browser.getWindowForTarget", targetId: @target_id) + @window_id, @bounds = result.values_at("windowId", "bounds") + + if fullscreen + @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { windowState: "fullscreen" }) + else + @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { windowState: "normal" }) + @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { width: width, height: height, windowState: "normal" }) + command("Emulation.setDeviceMetricsOverride", width: width, height: height, deviceScaleFactor: 1, mobile: false) + end + end + + def refresh + @wait = timeout + command("Page.reload") + end + + def network_traffic(type = nil) + case type.to_s + when "all" + @network_traffic + when "blocked" + @network_traffic.select { |r| r.response.nil? } # when request blocked + else + @network_traffic.select { |r| r.response } # when request isn't blocked + end + end + + def clear_network_traffic + @network_traffic = [] + end + + def go_back + go(-1) + end + + def go_forward + go(1) + end + + def accept_confirm + @accept_modal << true + end + + def dismiss_confirm + @accept_modal << false + end + + def accept_prompt(modal_response) + @accept_modal << true + @modal_response = modal_response + end + + def dismiss_prompt + @accept_modal << false + 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] + 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 + + begin + modal_text = @modal_messages.shift + raise ModalNotFound if modal_text.nil? || (expect_text && !modal_text.match(expect_regexp)) + rescue ModalNotFound => e + raise e, not_found_msg if (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time) >= timeout_sec + sleep(0.05) + retry + end + + modal_text + end + + def reset_modals + @accept_modal = [] + @modal_response = nil + @modal_messages = [] + end + + def command(*args) + id = nil + + @mutex.synchronize do + id = @client.command(*args) + stop_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @wait + + while @wait > 0 && (remain = stop_at - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)) > 0 + @resource.wait(@mutex, remain) + end + + @wait = 0 + end + + @client.wait(id: id) + end + + private + + def on_events + super + + if @browser.logger + @client.on("Runtime.consoleAPICalled") do |params| + params["args"].each { |r| @browser.logger.puts(r["value"]) } + end + end + + if @browser.js_errors + @client.on("Runtime.exceptionThrown") do |params| + Thread.main.raise JavaScriptError.new(params.dig("exceptionDetails", "exception")) + end + end + + @client.on("Page.javascriptDialogOpening") do |params| + accept_modal = @accept_modal.last + if accept_modal == true || accept_modal == false + @accept_modal.pop + @modal_messages << params["message"] + options = { accept: accept_modal } + response = @modal_response || params["defaultPrompt"] + options.merge!(promptText: response) if response + @client.command("Page.handleJavaScriptDialog", **options) + else + warn "Modal window has been opened, but you didn't wrap your code into (`accept_prompt` | `dismiss_prompt` | `accept_confirm` | `dismiss_confirm` | `accept_alert`), accepting by default" + options = { accept: true } + response = params["defaultPrompt"] + options.merge!(promptText: response) if response + @client.command("Page.handleJavaScriptDialog", **options) + end + end + + @client.on("Page.windowOpen") do + @browser.targets.refresh + @mutex.try_lock + sleep 0.3 # Dirty hack because new window doesn't have events at all + @mutex.unlock if @mutex.locked? && @mutex.owned? + end + + @client.on("Page.navigatedWithinDocument") do + signal if @waiting_frames.empty? + end + + @client.on("Page.domContentEventFired") do |params| + # `frameStoppedLoading` doesn't occur if status isn't success + if @status != 200 + signal + @client.command("DOM.getDocument", depth: 0) + end + end + + @client.on("Network.requestWillBeSent") do |params| + if params["frameId"] == @frame_id + # Possible types: + # Document, Stylesheet, Image, Media, Font, Script, TextTrack, XHR, + # Fetch, EventSource, WebSocket, Manifest, SignedExchange, Ping, + # CSPViolationReport, Other + if params["type"] == "Document" + @mutex.try_lock + @request_id = params["requestId"] + end + end + + id, time = params.values_at("requestId", "wallTime") + params = params["request"].merge("id" => id, "time" => time) + @network_traffic << Network::Request.new(params) + end + + @client.on("Network.responseReceived") do |params| + if params["requestId"] == @request_id + @response_headers = params.dig("response", "headers") + @status = params.dig("response", "status") + end + + if request = @network_traffic.find { |r| r.id == params["requestId"] } + params = params["response"].merge("id" => params["requestId"]) + request.response = Network::Response.new(params) + end + end + + @client.on("Network.loadingFinished") do |params| + if request = @network_traffic.find { |r| r.id == params["requestId"] } + # Sometimes we never get the Network.responseReceived event. + # See https://crbug.com/883475 + # + # Network.loadingFinished's encodedDataLength contains both body and headers + # sizes received by wire. See https://crbug.com/764946 + if response = request.response + response.body_size = params["encodedDataLength"] - response.headers_size + end + end + end + + @client.on("Log.entryAdded") do |params| + source = params.dig("entry", "source") + level = params.dig("entry", "level") + if source == "network" && level == "error" + id = params.dig("entry", "networkRequestId") + if request = @network_traffic.find { |r| r.id == id } + request.error = Network::Error.new(params["entry"]) + end + end + end + end + + def prepare_page + command("Page.enable") + command("DOM.enable") + command("CSS.enable") + command("Runtime.enable") + command("Log.enable") + command("Network.enable") + + if @browser.options[:save_path] + command("Page.setDownloadBehavior", behavior: "allow", downloadPath: @browser.options[:save_path]) + end + + @browser.extensions.each do |extension| + @client.command("Page.addScriptToEvaluateOnNewDocument", source: extension) + end + + inject_extensions + + width, height = @browser.window_size + resize(width: width, height: height) + + url_whitelist = Array(@browser.url_whitelist) + url_blacklist = Array(@browser.url_blacklist) + intercept_request("*") if !url_whitelist.empty? || !url_blacklist.empty? + + response = command("Page.getNavigationHistory") + if response.dig("entries", 0, "transitionType") != "typed" + # If we create page by clicking links, submiting forms and so on it + # opens a new window for which `frameStoppedLoading` event never + # occurs and thus search for nodes cannot be completed. Here we check + # the history and if the transitionType for example `link` then + # content is already loaded and we can try to get the document. + @client.command("DOM.getDocument", depth: 0) + end + end + + def inject_extensions + @browser.extensions.each do |extension| + # https://github.com/GoogleChrome/puppeteer/issues/1443 + # https://github.com/ChromeDevTools/devtools-protocol/issues/77 + # https://github.com/cyrus-and/chrome-remote-interface/issues/319 + # We also evaluate script just in case because + # `Page.addScriptToEvaluateOnNewDocument` doesn't work in popups. + @client.command("Runtime.evaluate", expression: extension, + contextId: execution_context_id, + returnByValue: true) + end + end + + def signal + @wait = 0 + + if @mutex.locked? && @mutex.owned? + @resource.signal + @mutex.unlock + else + @mutex.synchronize { @resource.signal } + end + end + + def go(delta) + history = command("Page.getNavigationHistory") + index, entries = history.values_at("currentIndex", "entries") + + if entry = entries[index + delta] + @wait = 0.05 # Potential wait because of network event + command("Page.navigateToHistoryEntry", entryId: entry["id"]) + end + end + + def combine_url!(url_or_path) + url = Addressable::URI.parse(url_or_path) + nil_or_relative = url.nil? || url.relative? + + if nil_or_relative && !@browser.base_url + raise "Set :base_url browser's option or use absolute url in `goto`, you passed: #{url_or_path}" + end + + nil_or_relative ? @browser.base_url.join(url.to_s) : url + end + end +end diff --git a/lib/ferrum/page/dom.rb b/lib/ferrum/page/dom.rb new file mode 100644 index 00000000..883c3441 --- /dev/null +++ b/lib/ferrum/page/dom.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Ferrum + class Page + module DOM + def current_url + evaluate_in(execution_context_id, "window.top.location.href") + end + + def title + evaluate_in(execution_context_id, "window.top.document.title") + end + + def body + evaluate("document.documentElement.outerHTML") + end + + def property(node, name) + evaluate_on(node: node, expression: %Q(this["#{name}"])) + end + + def select_file(node, value) + command("DOM.setFileInputFiles", nodeId: node.node_id, files: Array(value)) + end + + def at_xpath(selector, within: nil) + raise NotImplemented + end + + def xpath(selector, within: nil) + raise NotImplemented + end + + def css(selector, within: nil) + # FIXME: check node type and remove static 1 + node_id = within&.node_id || 1 + + ids = command("DOM.querySelectorAll", + nodeId: node_id, + selector: selector)["nodeIds"] + ids.map { |id| _build_node(id) }.compact + end + + def at_css(selector, within: nil) + # FIXME: check node type and remove static 1 + node_id = within&.node_id || 1 + + id = command("DOM.querySelector", + nodeId: node_id, + selector: selector)["nodeId"] + _build_node(id) + end + + private + + def _build_node(node_id) + description = command("DOM.describeNode", nodeId: node_id) + Node.new(self, target_id, node_id, description["node"]) + end + end + end +end diff --git a/lib/ferrum/page/frame.rb b/lib/ferrum/page/frame.rb new file mode 100644 index 00000000..ad8ea6a4 --- /dev/null +++ b/lib/ferrum/page/frame.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Ferrum + class Page + module Frame + def execution_context_id + @mutex.synchronize do + if !@frame_stack.empty? + @frames[@frame_stack.last]["execution_context_id"] + else + @execution_context_id + end + end + end + + def frame_name + evaluate("window.name") + end + + def frame_url + evaluate("window.location.href") + end + + def frame_title + evaluate("document.title") + end + + def switch_to_frame(handle) + case handle + when :parent + @frame_stack.pop + when :top + @frame_stack = [] + else + @frame_stack << handle + inject_extensions + end + end + + private + + def on_events + super if defined?(super) + + @client.on("Page.frameAttached") do |params| + @frames[params["frameId"]] = { "parent_id" => params["parentFrameId"] } + end + + @client.on("Page.frameStartedLoading") do |params| + @waiting_frames << params["frameId"] + @mutex.try_lock + end + + @client.on("Page.frameNavigated") do |params| + id = params["frame"]["id"] + if frame = @frames[id] + frame.merge!(params["frame"].select { |k, v| k == "name" || k == "url" }) + end + end + + @client.on("Page.frameScheduledNavigation") do |params| + # Trying to lock mutex if frame is the main frame + @waiting_frames << params["frameId"] + @mutex.try_lock + end + + @client.on("Page.frameStoppedLoading") do |params| + # `DOM.performSearch` doesn't work without getting #document node first. + # It returns node with nodeId 1 and nodeType 9 from which descend the + # tree and we save it in a variable because if we call that again root + # node will change the id and all subsequent nodes have to change id too. + # `command` is not allowed in the block as it will deadlock the process. + if params["frameId"] == @frame_id + signal if @waiting_frames.empty? + @client.command("DOM.getDocument", depth: 0) + end + + if @waiting_frames.include?(params["frameId"]) + @waiting_frames.delete(params["frameId"]) + signal if @waiting_frames.empty? + end + end + + @client.on("Runtime.executionContextCreated") do |params| + frame_id = params.dig("context", "auxData", "frameId") + execution_context_id = params.dig("context", "id") + + # Remember the very first frame since it's the main one + @frame_id ||= frame_id + @execution_context_id ||= execution_context_id + + if @frames[frame_id] + @frames[frame_id].merge!("execution_context_id" => execution_context_id) + else + @frames[frame_id] = { "execution_context_id" => execution_context_id } + end + end + + @client.on("Runtime.executionContextDestroyed") do |params| + execution_context_id = params["executionContextId"] + _id, frame = @frames.find { |_, p| p["execution_context_id"] == execution_context_id } + frame["execution_context_id"] = nil if frame + + if @execution_context_id == execution_context_id + @execution_context_id = nil + end + end + + @client.on("Runtime.executionContextsCleared") do + # If we didn't have time to set context id at the beginning we have + # to set lock and release it when we set something. + @execution_context_id = nil + end + end + end + end +end diff --git a/lib/ferrum/page/input.json b/lib/ferrum/page/input.json new file mode 100644 index 00000000..e6b67545 --- /dev/null +++ b/lib/ferrum/page/input.json @@ -0,0 +1,1341 @@ +{ + "0": { + "windowsVirtualKeyCode": 48, + "key": "0", + "code": "Digit0" + }, + "1": { + "windowsVirtualKeyCode": 49, + "key": "1", + "code": "Digit1" + }, + "2": { + "windowsVirtualKeyCode": 50, + "key": "2", + "code": "Digit2" + }, + "3": { + "windowsVirtualKeyCode": 51, + "key": "3", + "code": "Digit3" + }, + "4": { + "windowsVirtualKeyCode": 52, + "key": "4", + "code": "Digit4" + }, + "5": { + "windowsVirtualKeyCode": 53, + "key": "5", + "code": "Digit5" + }, + "6": { + "windowsVirtualKeyCode": 54, + "key": "6", + "code": "Digit6" + }, + "7": { + "windowsVirtualKeyCode": 55, + "key": "7", + "code": "Digit7" + }, + "8": { + "windowsVirtualKeyCode": 56, + "key": "8", + "code": "Digit8" + }, + "9": { + "windowsVirtualKeyCode": 57, + "key": "9", + "code": "Digit9" + }, + "Power": { + "key": "Power", + "code": "Power" + }, + "Eject": { + "key": "Eject", + "code": "Eject" + }, + "Abort": { + "windowsVirtualKeyCode": 3, + "code": "Abort", + "key": "Cancel" + }, + "Help": { + "windowsVirtualKeyCode": 6, + "code": "Help", + "key": "Help" + }, + "Backspace": { + "windowsVirtualKeyCode": 8, + "code": "Backspace", + "key": "Backspace" + }, + "Tab": { + "windowsVirtualKeyCode": 9, + "code": "Tab", + "key": "Tab" + }, + "Numpad5": { + "windowsVirtualKeyCode": 101, + "shiftKeyCode": 12, + "key": "Clear", + "code": "Numpad5", + "shiftKey": "5", + "location": 3 + }, + "NumpadEnter": { + "windowsVirtualKeyCode": 13, + "code": "NumpadEnter", + "key": "Enter", + "text": "\r", + "location": 3 + }, + "Enter": { + "windowsVirtualKeyCode": 13, + "code": "Enter", + "key": "Enter", + "text": "\r" + }, + "\r": { + "windowsVirtualKeyCode": 13, + "code": "Enter", + "key": "Enter", + "text": "\r" + }, + "\n": { + "windowsVirtualKeyCode": 13, + "code": "Enter", + "key": "Enter", + "text": "\r" + }, + "ShiftLeft": { + "windowsVirtualKeyCode": 16, + "code": "ShiftLeft", + "key": "Shift", + "location": 1 + }, + "ShiftRight": { + "windowsVirtualKeyCode": 16, + "code": "ShiftRight", + "key": "Shift", + "location": 2 + }, + "ControlLeft": { + "windowsVirtualKeyCode": 17, + "code": "ControlLeft", + "key": "Control", + "location": 1 + }, + "ControlRight": { + "windowsVirtualKeyCode": 17, + "code": "ControlRight", + "key": "Control", + "location": 2 + }, + "AltLeft": { + "windowsVirtualKeyCode": 18, + "code": "AltLeft", + "key": "Alt", + "location": 1 + }, + "AltRight": { + "windowsVirtualKeyCode": 18, + "code": "AltRight", + "key": "Alt", + "location": 2 + }, + "Pause": { + "windowsVirtualKeyCode": 19, + "code": "Pause", + "key": "Pause" + }, + "CapsLock": { + "windowsVirtualKeyCode": 20, + "code": "CapsLock", + "key": "CapsLock" + }, + "Escape": { + "windowsVirtualKeyCode": 27, + "code": "Escape", + "key": "Escape" + }, + "Convert": { + "windowsVirtualKeyCode": 28, + "code": "Convert", + "key": "Convert" + }, + "NonConvert": { + "windowsVirtualKeyCode": 29, + "code": "NonConvert", + "key": "NonConvert" + }, + "Space": { + "windowsVirtualKeyCode": 32, + "code": "Space", + "key": " ", + "text": " " + }, + "Numpad9": { + "windowsVirtualKeyCode": 105, + "shiftKeyCode": 33, + "key": "PageUp", + "code": "Numpad9", + "shiftKey": "9", + "location": 3 + }, + "PageUp": { + "windowsVirtualKeyCode": 33, + "code": "PageUp", + "key": "PageUp" + }, + "Numpad3": { + "windowsVirtualKeyCode": 99, + "shiftKeyCode": 34, + "key": "PageDown", + "code": "Numpad3", + "shiftKey": "3", + "location": 3 + }, + "PageDown": { + "windowsVirtualKeyCode": 34, + "code": "PageDown", + "key": "PageDown" + }, + "End": { + "windowsVirtualKeyCode": 35, + "code": "End", + "key": "End" + }, + "Numpad1": { + "windowsVirtualKeyCode": 97, + "shiftKeyCode": 35, + "key": "End", + "code": "Numpad1", + "shiftKey": "1", + "location": 3 + }, + "Home": { + "windowsVirtualKeyCode": 36, + "code": "Home", + "key": "Home" + }, + "Numpad7": { + "windowsVirtualKeyCode": 103, + "shiftKeyCode": 36, + "key": "Home", + "code": "Numpad7", + "shiftKey": "7", + "location": 3 + }, + "ArrowLeft": { + "windowsVirtualKeyCode": 37, + "code": "ArrowLeft", + "key": "ArrowLeft" + }, + "Numpad4": { + "windowsVirtualKeyCode": 100, + "shiftKeyCode": 37, + "key": "ArrowLeft", + "code": "Numpad4", + "shiftKey": "4", + "location": 3 + }, + "Numpad8": { + "windowsVirtualKeyCode": 104, + "shiftKeyCode": 38, + "key": "ArrowUp", + "code": "Numpad8", + "shiftKey": "8", + "location": 3 + }, + "ArrowUp": { + "windowsVirtualKeyCode": 38, + "code": "ArrowUp", + "key": "ArrowUp" + }, + "ArrowRight": { + "windowsVirtualKeyCode": 39, + "code": "ArrowRight", + "key": "ArrowRight" + }, + "Numpad6": { + "windowsVirtualKeyCode": 102, + "shiftKeyCode": 39, + "key": "ArrowRight", + "code": "Numpad6", + "shiftKey": "6", + "location": 3 + }, + "Numpad2": { + "windowsVirtualKeyCode": 98, + "shiftKeyCode": 40, + "key": "ArrowDown", + "code": "Numpad2", + "shiftKey": "2", + "location": 3 + }, + "ArrowDown": { + "windowsVirtualKeyCode": 40, + "code": "ArrowDown", + "key": "ArrowDown" + }, + "Select": { + "windowsVirtualKeyCode": 41, + "code": "Select", + "key": "Select" + }, + "Open": { + "windowsVirtualKeyCode": 43, + "code": "Open", + "key": "Execute" + }, + "PrintScreen": { + "windowsVirtualKeyCode": 44, + "code": "PrintScreen", + "key": "PrintScreen" + }, + "Insert": { + "windowsVirtualKeyCode": 45, + "code": "Insert", + "key": "Insert" + }, + "Numpad0": { + "windowsVirtualKeyCode": 96, + "shiftKeyCode": 45, + "key": "Insert", + "code": "Numpad0", + "shiftKey": "0", + "location": 3 + }, + "Delete": { + "windowsVirtualKeyCode": 46, + "code": "Delete", + "key": "Delete" + }, + "NumpadDecimal": { + "windowsVirtualKeyCode": 110, + "shiftKeyCode": 46, + "code": "NumpadDecimal", + "key": "\u0000", + "shiftKey": ".", + "location": 3 + }, + "Digit0": { + "windowsVirtualKeyCode": 48, + "code": "Digit0", + "shiftKey": ")", + "key": "0" + }, + "Digit1": { + "windowsVirtualKeyCode": 49, + "code": "Digit1", + "shiftKey": "!", + "key": "1" + }, + "Digit2": { + "windowsVirtualKeyCode": 50, + "code": "Digit2", + "shiftKey": "@", + "key": "2" + }, + "Digit3": { + "windowsVirtualKeyCode": 51, + "code": "Digit3", + "shiftKey": "#", + "key": "3" + }, + "Digit4": { + "windowsVirtualKeyCode": 52, + "code": "Digit4", + "shiftKey": "$", + "key": "4" + }, + "Digit5": { + "windowsVirtualKeyCode": 53, + "code": "Digit5", + "shiftKey": "%", + "key": "5" + }, + "Digit6": { + "windowsVirtualKeyCode": 54, + "code": "Digit6", + "shiftKey": "^", + "key": "6" + }, + "Digit7": { + "windowsVirtualKeyCode": 55, + "code": "Digit7", + "shiftKey": "&", + "key": "7" + }, + "Digit8": { + "windowsVirtualKeyCode": 56, + "code": "Digit8", + "shiftKey": "*", + "key": "8" + }, + "Digit9": { + "windowsVirtualKeyCode": 57, + "code": "Digit9", + "shiftKey": "(", + "key": "9" + }, + "KeyA": { + "windowsVirtualKeyCode": 65, + "code": "KeyA", + "shiftKey": "A", + "key": "a" + }, + "KeyB": { + "windowsVirtualKeyCode": 66, + "code": "KeyB", + "shiftKey": "B", + "key": "b" + }, + "KeyC": { + "windowsVirtualKeyCode": 67, + "code": "KeyC", + "shiftKey": "C", + "key": "c" + }, + "KeyD": { + "windowsVirtualKeyCode": 68, + "code": "KeyD", + "shiftKey": "D", + "key": "d" + }, + "KeyE": { + "windowsVirtualKeyCode": 69, + "code": "KeyE", + "shiftKey": "E", + "key": "e" + }, + "KeyF": { + "windowsVirtualKeyCode": 70, + "code": "KeyF", + "shiftKey": "F", + "key": "f" + }, + "KeyG": { + "windowsVirtualKeyCode": 71, + "code": "KeyG", + "shiftKey": "G", + "key": "g" + }, + "KeyH": { + "windowsVirtualKeyCode": 72, + "code": "KeyH", + "shiftKey": "H", + "key": "h" + }, + "KeyI": { + "windowsVirtualKeyCode": 73, + "code": "KeyI", + "shiftKey": "I", + "key": "i" + }, + "KeyJ": { + "windowsVirtualKeyCode": 74, + "code": "KeyJ", + "shiftKey": "J", + "key": "j" + }, + "KeyK": { + "windowsVirtualKeyCode": 75, + "code": "KeyK", + "shiftKey": "K", + "key": "k" + }, + "KeyL": { + "windowsVirtualKeyCode": 76, + "code": "KeyL", + "shiftKey": "L", + "key": "l" + }, + "KeyM": { + "windowsVirtualKeyCode": 77, + "code": "KeyM", + "shiftKey": "M", + "key": "m" + }, + "KeyN": { + "windowsVirtualKeyCode": 78, + "code": "KeyN", + "shiftKey": "N", + "key": "n" + }, + "KeyO": { + "windowsVirtualKeyCode": 79, + "code": "KeyO", + "shiftKey": "O", + "key": "o" + }, + "KeyP": { + "windowsVirtualKeyCode": 80, + "code": "KeyP", + "shiftKey": "P", + "key": "p" + }, + "KeyQ": { + "windowsVirtualKeyCode": 81, + "code": "KeyQ", + "shiftKey": "Q", + "key": "q" + }, + "KeyR": { + "windowsVirtualKeyCode": 82, + "code": "KeyR", + "shiftKey": "R", + "key": "r" + }, + "KeyS": { + "windowsVirtualKeyCode": 83, + "code": "KeyS", + "shiftKey": "S", + "key": "s" + }, + "KeyT": { + "windowsVirtualKeyCode": 84, + "code": "KeyT", + "shiftKey": "T", + "key": "t" + }, + "KeyU": { + "windowsVirtualKeyCode": 85, + "code": "KeyU", + "shiftKey": "U", + "key": "u" + }, + "KeyV": { + "windowsVirtualKeyCode": 86, + "code": "KeyV", + "shiftKey": "V", + "key": "v" + }, + "KeyW": { + "windowsVirtualKeyCode": 87, + "code": "KeyW", + "shiftKey": "W", + "key": "w" + }, + "KeyX": { + "windowsVirtualKeyCode": 88, + "code": "KeyX", + "shiftKey": "X", + "key": "x" + }, + "KeyY": { + "windowsVirtualKeyCode": 89, + "code": "KeyY", + "shiftKey": "Y", + "key": "y" + }, + "KeyZ": { + "windowsVirtualKeyCode": 90, + "code": "KeyZ", + "shiftKey": "Z", + "key": "z" + }, + "MetaLeft": { + "windowsVirtualKeyCode": 91, + "code": "MetaLeft", + "key": "Meta", + "location": 1 + }, + "MetaRight": { + "windowsVirtualKeyCode": 92, + "code": "MetaRight", + "key": "Meta", + "location": 2 + }, + "ContextMenu": { + "windowsVirtualKeyCode": 93, + "code": "ContextMenu", + "key": "ContextMenu" + }, + "NumpadMultiply": { + "windowsVirtualKeyCode": 106, + "code": "NumpadMultiply", + "key": "*", + "location": 3 + }, + "NumpadAdd": { + "windowsVirtualKeyCode": 107, + "code": "NumpadAdd", + "key": "+", + "location": 3 + }, + "NumpadSubtract": { + "windowsVirtualKeyCode": 109, + "code": "NumpadSubtract", + "key": "-", + "location": 3 + }, + "NumpadDivide": { + "windowsVirtualKeyCode": 111, + "code": "NumpadDivide", + "key": "/", + "location": 3 + }, + "F1": { + "windowsVirtualKeyCode": 112, + "code": "F1", + "key": "F1" + }, + "F2": { + "windowsVirtualKeyCode": 113, + "code": "F2", + "key": "F2" + }, + "F3": { + "windowsVirtualKeyCode": 114, + "code": "F3", + "key": "F3" + }, + "F4": { + "windowsVirtualKeyCode": 115, + "code": "F4", + "key": "F4" + }, + "F5": { + "windowsVirtualKeyCode": 116, + "code": "F5", + "key": "F5" + }, + "F6": { + "windowsVirtualKeyCode": 117, + "code": "F6", + "key": "F6" + }, + "F7": { + "windowsVirtualKeyCode": 118, + "code": "F7", + "key": "F7" + }, + "F8": { + "windowsVirtualKeyCode": 119, + "code": "F8", + "key": "F8" + }, + "F9": { + "windowsVirtualKeyCode": 120, + "code": "F9", + "key": "F9" + }, + "F10": { + "windowsVirtualKeyCode": 121, + "code": "F10", + "key": "F10" + }, + "F11": { + "windowsVirtualKeyCode": 122, + "code": "F11", + "key": "F11" + }, + "F12": { + "windowsVirtualKeyCode": 123, + "code": "F12", + "key": "F12" + }, + "F13": { + "windowsVirtualKeyCode": 124, + "code": "F13", + "key": "F13" + }, + "F14": { + "windowsVirtualKeyCode": 125, + "code": "F14", + "key": "F14" + }, + "F15": { + "windowsVirtualKeyCode": 126, + "code": "F15", + "key": "F15" + }, + "F16": { + "windowsVirtualKeyCode": 127, + "code": "F16", + "key": "F16" + }, + "F17": { + "windowsVirtualKeyCode": 128, + "code": "F17", + "key": "F17" + }, + "F18": { + "windowsVirtualKeyCode": 129, + "code": "F18", + "key": "F18" + }, + "F19": { + "windowsVirtualKeyCode": 130, + "code": "F19", + "key": "F19" + }, + "F20": { + "windowsVirtualKeyCode": 131, + "code": "F20", + "key": "F20" + }, + "F21": { + "windowsVirtualKeyCode": 132, + "code": "F21", + "key": "F21" + }, + "F22": { + "windowsVirtualKeyCode": 133, + "code": "F22", + "key": "F22" + }, + "F23": { + "windowsVirtualKeyCode": 134, + "code": "F23", + "key": "F23" + }, + "F24": { + "windowsVirtualKeyCode": 135, + "code": "F24", + "key": "F24" + }, + "NumLock": { + "windowsVirtualKeyCode": 144, + "code": "NumLock", + "key": "NumLock" + }, + "ScrollLock": { + "windowsVirtualKeyCode": 145, + "code": "ScrollLock", + "key": "ScrollLock" + }, + "AudioVolumeMute": { + "windowsVirtualKeyCode": 173, + "code": "AudioVolumeMute", + "key": "AudioVolumeMute" + }, + "AudioVolumeDown": { + "windowsVirtualKeyCode": 174, + "code": "AudioVolumeDown", + "key": "AudioVolumeDown" + }, + "AudioVolumeUp": { + "windowsVirtualKeyCode": 175, + "code": "AudioVolumeUp", + "key": "AudioVolumeUp" + }, + "MediaTrackNext": { + "windowsVirtualKeyCode": 176, + "code": "MediaTrackNext", + "key": "MediaTrackNext" + }, + "MediaTrackPrevious": { + "windowsVirtualKeyCode": 177, + "code": "MediaTrackPrevious", + "key": "MediaTrackPrevious" + }, + "MediaStop": { + "windowsVirtualKeyCode": 178, + "code": "MediaStop", + "key": "MediaStop" + }, + "MediaPlayPause": { + "windowsVirtualKeyCode": 179, + "code": "MediaPlayPause", + "key": "MediaPlayPause" + }, + "Semicolon": { + "windowsVirtualKeyCode": 186, + "code": "Semicolon", + "shiftKey": ":", + "key": ";" + }, + "Equal": { + "windowsVirtualKeyCode": 187, + "code": "Equal", + "shiftKey": "+", + "key": "=" + }, + "NumpadEqual": { + "windowsVirtualKeyCode": 187, + "code": "NumpadEqual", + "key": "=", + "location": 3 + }, + "Comma": { + "windowsVirtualKeyCode": 188, + "code": "Comma", + "shiftKey": "<", + "key": "," + }, + "Minus": { + "windowsVirtualKeyCode": 189, + "code": "Minus", + "shiftKey": "_", + "key": "-" + }, + "Period": { + "windowsVirtualKeyCode": 190, + "code": "Period", + "shiftKey": ">", + "key": "." + }, + "Slash": { + "windowsVirtualKeyCode": 191, + "code": "Slash", + "shiftKey": "?", + "key": "/" + }, + "Backquote": { + "windowsVirtualKeyCode": 192, + "code": "Backquote", + "shiftKey": "~", + "key": "`" + }, + "BracketLeft": { + "windowsVirtualKeyCode": 219, + "code": "BracketLeft", + "shiftKey": "{", + "key": "[" + }, + "Backslash": { + "windowsVirtualKeyCode": 220, + "code": "Backslash", + "shiftKey": "|", + "key": "\\" + }, + "BracketRight": { + "windowsVirtualKeyCode": 221, + "code": "BracketRight", + "shiftKey": "}", + "key": "]" + }, + "Quote": { + "windowsVirtualKeyCode": 222, + "code": "Quote", + "shiftKey": "\"", + "key": "'" + }, + "AltGraph": { + "windowsVirtualKeyCode": 225, + "code": "AltGraph", + "key": "AltGraph" + }, + "Props": { + "windowsVirtualKeyCode": 247, + "code": "Props", + "key": "CrSel" + }, + "Cancel": { + "windowsVirtualKeyCode": 3, + "key": "Cancel", + "code": "Abort" + }, + "Clear": { + "windowsVirtualKeyCode": 12, + "key": "Clear", + "code": "Numpad5", + "location": 3 + }, + "Shift": { + "windowsVirtualKeyCode": 16, + "key": "Shift", + "code": "ShiftLeft", + "location": 1 + }, + "Control": { + "windowsVirtualKeyCode": 17, + "key": "Control", + "code": "ControlLeft", + "location": 1 + }, + "Alt": { + "windowsVirtualKeyCode": 18, + "key": "Alt", + "code": "AltLeft", + "location": 1 + }, + "Accept": { + "windowsVirtualKeyCode": 30, + "key": "Accept" + }, + "ModeChange": { + "windowsVirtualKeyCode": 31, + "key": "ModeChange" + }, + " ": { + "windowsVirtualKeyCode": 32, + "key": " ", + "code": "Space" + }, + "Print": { + "windowsVirtualKeyCode": 42, + "key": "Print" + }, + "Execute": { + "windowsVirtualKeyCode": 43, + "key": "Execute", + "code": "Open" + }, + "\u0000": { + "windowsVirtualKeyCode": 46, + "key": "\u0000", + "code": "NumpadDecimal", + "location": 3 + }, + "a": { + "windowsVirtualKeyCode": 65, + "key": "a", + "code": "KeyA" + }, + "b": { + "windowsVirtualKeyCode": 66, + "key": "b", + "code": "KeyB" + }, + "c": { + "windowsVirtualKeyCode": 67, + "key": "c", + "code": "KeyC" + }, + "d": { + "windowsVirtualKeyCode": 68, + "key": "d", + "code": "KeyD" + }, + "e": { + "windowsVirtualKeyCode": 69, + "key": "e", + "code": "KeyE" + }, + "f": { + "windowsVirtualKeyCode": 70, + "key": "f", + "code": "KeyF" + }, + "g": { + "windowsVirtualKeyCode": 71, + "key": "g", + "code": "KeyG" + }, + "h": { + "windowsVirtualKeyCode": 72, + "key": "h", + "code": "KeyH" + }, + "i": { + "windowsVirtualKeyCode": 73, + "key": "i", + "code": "KeyI" + }, + "j": { + "windowsVirtualKeyCode": 74, + "key": "j", + "code": "KeyJ" + }, + "k": { + "windowsVirtualKeyCode": 75, + "key": "k", + "code": "KeyK" + }, + "l": { + "windowsVirtualKeyCode": 76, + "key": "l", + "code": "KeyL" + }, + "m": { + "windowsVirtualKeyCode": 77, + "key": "m", + "code": "KeyM" + }, + "n": { + "windowsVirtualKeyCode": 78, + "key": "n", + "code": "KeyN" + }, + "o": { + "windowsVirtualKeyCode": 79, + "key": "o", + "code": "KeyO" + }, + "p": { + "windowsVirtualKeyCode": 80, + "key": "p", + "code": "KeyP" + }, + "q": { + "windowsVirtualKeyCode": 81, + "key": "q", + "code": "KeyQ" + }, + "r": { + "windowsVirtualKeyCode": 82, + "key": "r", + "code": "KeyR" + }, + "s": { + "windowsVirtualKeyCode": 83, + "key": "s", + "code": "KeyS" + }, + "t": { + "windowsVirtualKeyCode": 84, + "key": "t", + "code": "KeyT" + }, + "u": { + "windowsVirtualKeyCode": 85, + "key": "u", + "code": "KeyU" + }, + "v": { + "windowsVirtualKeyCode": 86, + "key": "v", + "code": "KeyV" + }, + "w": { + "windowsVirtualKeyCode": 87, + "key": "w", + "code": "KeyW" + }, + "x": { + "windowsVirtualKeyCode": 88, + "key": "x", + "code": "KeyX" + }, + "y": { + "windowsVirtualKeyCode": 89, + "key": "y", + "code": "KeyY" + }, + "z": { + "windowsVirtualKeyCode": 90, + "key": "z", + "code": "KeyZ" + }, + "Meta": { + "windowsVirtualKeyCode": 91, + "key": "Meta", + "code": "MetaLeft", + "location": 1 + }, + "*": { + "windowsVirtualKeyCode": 106, + "key": "*", + "code": "NumpadMultiply", + "location": 3 + }, + "+": { + "windowsVirtualKeyCode": 107, + "key": "+", + "code": "NumpadAdd", + "location": 3 + }, + "-": { + "windowsVirtualKeyCode": 109, + "key": "-", + "code": "NumpadSubtract", + "location": 3 + }, + "/": { + "windowsVirtualKeyCode": 111, + "key": "/", + "code": "NumpadDivide", + "location": 3 + }, + ";": { + "windowsVirtualKeyCode": 186, + "key": ";", + "code": "Semicolon" + }, + "=": { + "windowsVirtualKeyCode": 187, + "key": "=", + "code": "Equal" + }, + ",": { + "windowsVirtualKeyCode": 188, + "key": ",", + "code": "Comma" + }, + ".": { + "windowsVirtualKeyCode": 190, + "key": ".", + "code": "Period" + }, + "`": { + "windowsVirtualKeyCode": 192, + "key": "`", + "code": "Backquote" + }, + "[": { + "windowsVirtualKeyCode": 219, + "key": "[", + "code": "BracketLeft" + }, + "\\": { + "windowsVirtualKeyCode": 220, + "key": "\\", + "code": "Backslash" + }, + "]": { + "windowsVirtualKeyCode": 221, + "key": "]", + "code": "BracketRight" + }, + "'": { + "windowsVirtualKeyCode": 222, + "key": "'", + "code": "Quote" + }, + "Attn": { + "windowsVirtualKeyCode": 246, + "key": "Attn" + }, + "CrSel": { + "windowsVirtualKeyCode": 247, + "key": "CrSel", + "code": "Props" + }, + "ExSel": { + "windowsVirtualKeyCode": 248, + "key": "ExSel" + }, + "EraseEof": { + "windowsVirtualKeyCode": 249, + "key": "EraseEof" + }, + "Play": { + "windowsVirtualKeyCode": 250, + "key": "Play" + }, + "ZoomOut": { + "windowsVirtualKeyCode": 251, + "key": "ZoomOut" + }, + ")": { + "windowsVirtualKeyCode": 48, + "key": ")", + "code": "Digit0" + }, + "!": { + "windowsVirtualKeyCode": 49, + "key": "!", + "code": "Digit1" + }, + "@": { + "windowsVirtualKeyCode": 50, + "key": "@", + "code": "Digit2" + }, + "#": { + "windowsVirtualKeyCode": 51, + "key": "#", + "code": "Digit3" + }, + "$": { + "windowsVirtualKeyCode": 52, + "key": "$", + "code": "Digit4" + }, + "%": { + "windowsVirtualKeyCode": 53, + "key": "%", + "code": "Digit5" + }, + "^": { + "windowsVirtualKeyCode": 54, + "key": "^", + "code": "Digit6" + }, + "&": { + "windowsVirtualKeyCode": 55, + "key": "&", + "code": "Digit7" + }, + "(": { + "windowsVirtualKeyCode": 57, + "key": "(", + "code": "Digit9" + }, + "A": { + "windowsVirtualKeyCode": 65, + "key": "A", + "code": "KeyA" + }, + "B": { + "windowsVirtualKeyCode": 66, + "key": "B", + "code": "KeyB" + }, + "C": { + "windowsVirtualKeyCode": 67, + "key": "C", + "code": "KeyC" + }, + "D": { + "windowsVirtualKeyCode": 68, + "key": "D", + "code": "KeyD" + }, + "E": { + "windowsVirtualKeyCode": 69, + "key": "E", + "code": "KeyE" + }, + "F": { + "windowsVirtualKeyCode": 70, + "key": "F", + "code": "KeyF" + }, + "G": { + "windowsVirtualKeyCode": 71, + "key": "G", + "code": "KeyG" + }, + "H": { + "windowsVirtualKeyCode": 72, + "key": "H", + "code": "KeyH" + }, + "I": { + "windowsVirtualKeyCode": 73, + "key": "I", + "code": "KeyI" + }, + "J": { + "windowsVirtualKeyCode": 74, + "key": "J", + "code": "KeyJ" + }, + "K": { + "windowsVirtualKeyCode": 75, + "key": "K", + "code": "KeyK" + }, + "L": { + "windowsVirtualKeyCode": 76, + "key": "L", + "code": "KeyL" + }, + "M": { + "windowsVirtualKeyCode": 77, + "key": "M", + "code": "KeyM" + }, + "N": { + "windowsVirtualKeyCode": 78, + "key": "N", + "code": "KeyN" + }, + "O": { + "windowsVirtualKeyCode": 79, + "key": "O", + "code": "KeyO" + }, + "P": { + "windowsVirtualKeyCode": 80, + "key": "P", + "code": "KeyP" + }, + "Q": { + "windowsVirtualKeyCode": 81, + "key": "Q", + "code": "KeyQ" + }, + "R": { + "windowsVirtualKeyCode": 82, + "key": "R", + "code": "KeyR" + }, + "S": { + "windowsVirtualKeyCode": 83, + "key": "S", + "code": "KeyS" + }, + "T": { + "windowsVirtualKeyCode": 84, + "key": "T", + "code": "KeyT" + }, + "U": { + "windowsVirtualKeyCode": 85, + "key": "U", + "code": "KeyU" + }, + "V": { + "windowsVirtualKeyCode": 86, + "key": "V", + "code": "KeyV" + }, + "W": { + "windowsVirtualKeyCode": 87, + "key": "W", + "code": "KeyW" + }, + "X": { + "windowsVirtualKeyCode": 88, + "key": "X", + "code": "KeyX" + }, + "Y": { + "windowsVirtualKeyCode": 89, + "key": "Y", + "code": "KeyY" + }, + "Z": { + "windowsVirtualKeyCode": 90, + "key": "Z", + "code": "KeyZ" + }, + ":": { + "windowsVirtualKeyCode": 186, + "key": ":", + "code": "Semicolon" + }, + "<": { + "windowsVirtualKeyCode": 188, + "key": "<", + "code": "Comma" + }, + "_": { + "windowsVirtualKeyCode": 189, + "key": "_", + "code": "Minus" + }, + ">": { + "windowsVirtualKeyCode": 190, + "key": ">", + "code": "Period" + }, + "?": { + "windowsVirtualKeyCode": 191, + "key": "?", + "code": "Slash" + }, + "~": { + "windowsVirtualKeyCode": 192, + "key": "~", + "code": "Backquote" + }, + "{": { + "windowsVirtualKeyCode": 219, + "key": "{", + "code": "BracketLeft" + }, + "|": { + "windowsVirtualKeyCode": 220, + "key": "|", + "code": "Backslash" + }, + "}": { + "windowsVirtualKeyCode": 221, + "key": "}", + "code": "BracketRight" + }, + "\"": { + "windowsVirtualKeyCode": 222, + "key": "\"", + "code": "Quote" + } +} diff --git a/lib/ferrum/page/input.rb b/lib/ferrum/page/input.rb new file mode 100644 index 00000000..d9a0b71f --- /dev/null +++ b/lib/ferrum/page/input.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "json" + +module Ferrum + class Page + module Input + KEYS = JSON.parse(File.read(File.expand_path("../input.json", __FILE__))) + MODIFIERS = { "alt" => 1, "ctrl" => 2, "control" => 2, "meta" => 4, "command" => 4, "shift" => 8 } + KEYS_MAPPING = { + cancel: "Cancel", help: "Help", backspace: "Backspace", tab: "Tab", + clear: "Clear", return: "Enter", enter: "Enter", shift: "Shift", + ctrl: "Control", control: "Control", alt: "Alt", pause: "Pause", + escape: "Escape", space: "Space", pageup: "PageUp", page_up: "PageUp", + pagedown: "PageDown", page_down: "PageDown", end: "End", home: "Home", + left: "ArrowLeft", up: "ArrowUp", right: "ArrowRight", + down: "ArrowDown", insert: "Insert", delete: "Delete", + semicolon: "Semicolon", equals: "Equal", numpad0: "Numpad0", + numpad1: "Numpad1", numpad2: "Numpad2", numpad3: "Numpad3", + numpad4: "Numpad4", numpad5: "Numpad5", numpad6: "Numpad6", + numpad7: "Numpad7", numpad8: "Numpad8", numpad9: "Numpad9", + multiply: "NumpadMultiply", add: "NumpadAdd", + separator: "NumpadDecimal", subtract: "NumpadSubtract", + decimal: "NumpadDecimal", divide: "NumpadDivide", f1: "F1", f2: "F2", + f3: "F3", f4: "F4", f5: "F5", f6: "F6", f7: "F7", f8: "F8", f9: "F9", + f10: "F10", f11: "F11", f12: "F12", meta: "Meta", command: "Meta", + } + + def click(node, keys = [], offset = {}) + x, y, modifiers = prepare_before_click(__method__, node, keys, offset) + command("Input.dispatchMouseEvent", type: "mousePressed", modifiers: modifiers, button: "left", x: x, y: y, clickCount: 1) + @wait = 0.05 # Potential wait because if network event is triggered then we have to wait until it's over. + command("Input.dispatchMouseEvent", type: "mouseReleased", modifiers: modifiers, button: "left", x: x, y: y, clickCount: 1) + end + + def right_click(node, keys = [], offset = {}) + x, y, modifiers = prepare_before_click(__method__, node, keys, offset) + command("Input.dispatchMouseEvent", type: "mousePressed", modifiers: modifiers, button: "right", x: x, y: y, clickCount: 1) + command("Input.dispatchMouseEvent", type: "mouseReleased", modifiers: modifiers, button: "right", x: x, y: y, clickCount: 1) + end + + def double_click(node, keys = [], offset = {}) + x, y, modifiers = prepare_before_click(__method__, node, keys, offset) + command("Input.dispatchMouseEvent", type: "mousePressed", modifiers: modifiers, button: "left", x: x, y: y, clickCount: 2) + command("Input.dispatchMouseEvent", type: "mouseReleased", modifiers: modifiers, button: "left", x: x, y: y, clickCount: 2) + end + + def click_coordinates(x, y) + command("Input.dispatchMouseEvent", type: "mousePressed", button: "left", x: x, y: y, clickCount: 1) + @wait = 0.05 # Potential wait because if network event is triggered then we have to wait until it's over. + command("Input.dispatchMouseEvent", type: "mouseReleased", button: "left", x: x, y: y, clickCount: 1) + end + + def focus(node) + command("DOM.focus", nodeId: node.node_id) + end + + def hover(node) + raise NotImplemented + end + + def set(node, value) + raise NotImplemented + end + + def select(node, value) + raise NotImplemented + end + + def trigger(node, event) + raise NotImplemented + end + + def scroll_to(top, left) + execute("window.scrollTo(#{top}, #{left})") + end + + def send_keys(node, keys) + # click(node) + # focus(node) + + keys = normalize_keys(Array(keys)) + + keys.each do |key| + type = key[:text] ? "keyDown" : "rawKeyDown" + command("Input.dispatchKeyEvent", type: type, **key) + command("Input.dispatchKeyEvent", type: "keyUp", **key) + end + end + + def normalize_keys(keys, pressed_keys = [], memo = []) + case keys + when Array + pressed_keys.push([]) + memo += combine_strings(keys).map { |k| normalize_keys(k, pressed_keys, memo) } + pressed_keys.pop + memo.flatten.compact + when Symbol + key = keys.to_s.downcase + + if MODIFIERS.keys.include?(key) + pressed_keys.last.push(key) + nil + else + _key = KEYS.fetch(KEYS_MAPPING[key.to_sym] || key.to_sym) + _key[:modifiers] = pressed_keys.flatten.map { |k| MODIFIERS[k] }.reduce(0, :|) + to_options(_key) + end + when String + pressed = pressed_keys.flatten + keys.each_char.map do |char| + if pressed.empty? + key = KEYS[char] || {} + key = key.merge(text: char, unmodifiedText: char) + [to_options(key)] + else + key = KEYS[char] || {} + text = pressed == ["shift"] ? char.upcase : char + key = key.merge( + text: text, + unmodifiedText: text, + isKeypad: key["location"] == 3, + modifiers: pressed.map { |k| MODIFIERS[k] }.reduce(0, :|), + ) + + modifiers = pressed.map { |k| to_options(KEYS.fetch(KEYS_MAPPING[k.to_sym])) } + modifiers + [to_options(key)] + end.flatten + end + end + end + + def combine_strings(keys) + keys + .chunk { |k| k.is_a?(String) } + .map { |s, k| s ? [k.reduce(&:+)] : k } + .reduce(&:+) + end + + private + + def prepare_before_click(name, node, keys, offset) + # FIXME: scrollIntoViewport + # evaluate_on(node: node, expression: "this.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'})") + + x, y = calculate_quads(node, offset[:x], offset[:y]) + + modifiers = keys.map { |k| MODIFIERS[k.to_s] }.compact.reduce(0, :|) + + command("Input.dispatchMouseEvent", type: "mouseMoved", x: x, y: y) + + [x, y, modifiers] + end + + def calculate_quads(node, offset_x = nil, offset_y = nil) + quads = get_content_quads(node) + offset_x, offset_y = offset_x.to_i, offset_y.to_i + + if offset_x > 0 || offset_y > 0 + point = quads.first + [point[:x] + offset_x, point[:y] + offset_y] + else + x, y = quads.inject([0, 0]) do |memo, point| + [memo[0] + point[:x], + memo[1] + point[:y]] + end + [x / 4, y / 4] + end + end + + def get_content_quads(node) + result = command("DOM.getContentQuads", nodeId: node.node_id) + raise "Node is either not visible or not an HTMLElement" if result["quads"].size == 0 + + # FIXME: Case when a few quads returned + result["quads"].map do |quad| + [{x: quad[0], y: quad[1]}, + {x: quad[2], y: quad[3]}, + {x: quad[4], y: quad[5]}, + {x: quad[6], y: quad[7]}] + end.first + end + + def to_options(hash) + hash.inject({}) { |memo, (k, v)| memo.merge(k.to_sym => v) } + end + end + end +end diff --git a/lib/ferrum/page/net.rb b/lib/ferrum/page/net.rb new file mode 100644 index 00000000..cdbc6707 --- /dev/null +++ b/lib/ferrum/page/net.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Ferrum + class Page + module Net + def proxy_authorize(user, password) + if user && password + @proxy_username, @proxy_password = user, password + intercept_request("*") + end + end + + def authorize(user, password) + @username, @password = user, password + intercept_request("*") + end + + def intercept_request(patterns) + patterns = Array(patterns).map { |p| { urlPattern: p } } + @client.command("Network.setRequestInterception", patterns: patterns) + end + + def continue_request(interception_id, options = nil) + options ||= {} + options = options.merge(interceptionId: interception_id) + @client.command("Network.continueInterceptedRequest", **options) + end + + private + + def on_events + super if defined?(super) + + @client.on("Network.loadingFailed") do |params| + # Free mutex as we aborted main request we are waiting for + if params["requestId"] == @request_id && params["canceled"] == true + signal + @client.command("DOM.getDocument", depth: 0) + end + end + + @client.on("Network.requestIntercepted") do |params| + @authorized_ids ||= [] + @proxy_authorized_ids ||= [] + url = params.dig("request", "url") + interception_id = params["interceptionId"] + + if params["authChallenge"] + response = if params.dig("authChallenge", "source") == "Proxy" + if @proxy_authorized_ids.include?(interception_id) + { response: "CancelAuth" } + elsif @proxy_username && @proxy_password + { response: "ProvideCredentials", + username: @proxy_username, + password: @proxy_password } + else + { response: "CancelAuth" } + end + else + if @authorized_ids.include?(interception_id) + { response: "CancelAuth" } + elsif @username && @password + { response: "ProvideCredentials", + username: @username, + password: @password } + else + { response: "CancelAuth" } + end + end + + @authorized_ids << interception_id + continue_request(interception_id, authChallengeResponse: response) + elsif @browser.url_blacklist && !@browser.url_blacklist.empty? + if @browser.url_blacklist.any? { |r| r.match(url) } + continue_request(interception_id, errorReason: "Aborted") + else + continue_request(interception_id) + end + elsif @browser.url_whitelist && !@browser.url_whitelist.empty? + if @browser.url_whitelist.any? { |r| r.match(url) } + continue_request(interception_id) + else + continue_request(interception_id, errorReason: "Aborted") + end + else + continue_request(interception_id) + end + end + end + end + end +end diff --git a/lib/ferrum/page/runtime.rb b/lib/ferrum/page/runtime.rb new file mode 100644 index 00000000..b38a36f7 --- /dev/null +++ b/lib/ferrum/page/runtime.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +module Ferrum + class Page + module Runtime + EXECUTE_OPTIONS = { + returnByValue: true, + functionDeclaration: %Q(function() { %s }) + }.freeze + DEFAULT_OPTIONS = { + functionDeclaration: %Q(function() { return %s }) + }.freeze + EVALUATE_ASYNC_OPTIONS = { + awaitPromise: true, + functionDeclaration: %Q( + function() { + return new Promise((__resolve, __reject) => { + try { + arguments[arguments.length] = r => __resolve(r); + arguments.length = arguments.length + 1; + setTimeout(() => __reject(new Error("timed out promise")), %s); + %s + } catch(error) { + __reject(error); + } + }); + } + ) + }.freeze + + def evaluate(expression, *args) + response = call(expression, nil, nil, *args) + handle(response) + end + + def evaluate_in(context_id, expression) + response = call(expression, nil, { executionContextId: context_id }) + handle(response) + end + + def evaluate_on(node:, expression:, by_value: true, wait: 0) + object_id = command("DOM.resolveNode", nodeId: node.node_id).dig("object", "objectId") + options = DEFAULT_OPTIONS.merge(objectId: object_id) + options[:functionDeclaration] = options[:functionDeclaration] % expression + options.merge!(returnByValue: by_value) + + @wait = wait if wait > 0 + + response = command("Runtime.callFunctionOn", **options) + .dig("result").tap { |r| handle_error(r) } + + by_value ? response.dig("value") : handle(response) + end + + def evaluate_async(expression, wait_time, *args) + response = call(expression, wait_time * 1000, EVALUATE_ASYNC_OPTIONS, *args) + handle(response) + end + + def execute(expression, *args) + call(expression, nil, EXECUTE_OPTIONS, *args) + true + end + + private + + def call(expression, wait_time, options = nil, *args) + options ||= {} + args = prepare_args(args) + + options = DEFAULT_OPTIONS.merge(options) + expression = [wait_time, expression] if wait_time + options[:functionDeclaration] = options[:functionDeclaration] % expression + options = options.merge(arguments: args) + unless options[:executionContextId] + options = options.merge(executionContextId: execution_context_id) + end + + begin + attempts ||= 1 + response = command("Runtime.callFunctionOn", **options) + response.dig("result").tap { |r| handle_error(r) } + rescue BrowserError => e + case e.message + when "No node with given id found", "Could not find node with given id", "Cannot find context with specified id" + sleep 0.1 + attempts += 1 + options = options.merge(executionContextId: execution_context_id) + retry if attempts <= 3 + end + end + end + + # FIXME: We should have a central place to handle all type of errors + def handle_error(result) + return if result["subtype"] != "error" + + case result["description"] + when /\AError: timed out promise/ + raise ScriptTimeoutError + else + raise JavaScriptError.new(result) + end + end + + def prepare_args(args) + args.map do |arg| + if arg.is_a?(Node) + resolved = command("DOM.resolveNode", nodeId: arg.node_id) + { objectId: resolved["object"]["objectId"] } + elsif arg.is_a?(Hash) && arg["objectId"] + { objectId: arg["objectId"] } + else + { value: arg } + end + end + end + + def handle(response) + case response["type"] + when "boolean", "number", "string" + response["value"] + when "undefined" + nil + when "function" + {} + when "object" + object_id = response["objectId"] + + case response["subtype"] + when "node" + begin + node_id = command("DOM.requestNode", objectId: object_id)["nodeId"] + desc = command("DOM.describeNode", nodeId: node_id)["node"] + Node.new(self, target_id, node_id, desc) + rescue BrowserError => e + # Node has disappeared while we were trying to get it + raise if e.message != "Could not find node with given id" + end + when "array" + reduce_props(object_id, []) do |memo, key, value| + next(memo) unless (Integer(key) rescue nil) + value = value["objectId"] ? handle(value) : value["value"] + memo.insert(key.to_i, value) + end.compact + when "date" + response["description"] + when "null" + nil + else + reduce_props(object_id, {}) do |memo, key, value| + value = value["objectId"] ? handle(value) : value["value"] + memo.merge(key => value) + end + end + end + end + + def reduce_props(object_id, to) + if cyclic?(object_id).dig("result", "value") + return "(cyclic structure)" + else + props = command("Runtime.getProperties", objectId: object_id) + props["result"].reduce(to) do |memo, prop| + next(memo) unless prop["enumerable"] + yield(memo, prop["name"], prop["value"]) + end + end + end + + def cyclic?(object_id) + command("Runtime.callFunctionOn", + objectId: object_id, + returnByValue: true, + functionDeclaration: <<~JS + function() { + if (Array.isArray(this) && + this.every(e => e instanceof Node)) { + return false; + } + + try { + JSON.stringify(this); + return false; + } catch (e) { + return true; + } + } + JS + ) + end + end + end +end diff --git a/lib/ferrum/targets.rb b/lib/ferrum/targets.rb new file mode 100644 index 00000000..75965dd6 --- /dev/null +++ b/lib/ferrum/targets.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module Ferrum + class Targets + EmptyTargetsError = Class.new(RuntimeError) + TARGETS_RETRY_ATTEMPTS = 3 + TARGETS_RETRY_WAIT = 0.001 + + def initialize(browser) + @page = nil + @mutex = Mutex.new + @browser = browser + @_default = targets.first["targetId"] + + @browser.on("Target.detachedFromTarget") do |params| + page = remove_page(params["targetId"]) + page&.close_connection + end + + reset + end + + def push(target_id, page = nil) + @targets[target_id] = page + end + + def refresh + @mutex.synchronize do + targets.each { |t| push(t["targetId"]) if !default?(t) && !has?(t) } + end + end + + def page + raise NoSuchWindowError unless @page + @page + end + + def window_handle + page.target_id + end + + def window_handles + @mutex.synchronize { @targets.keys } + end + + def switch_to_window(target_id) + @page = find_or_create_page(target_id) + end + + def open_new_window + target_id = @browser.command("Target.createTarget", url: "about:blank", browserContextId: @_context_id)["targetId"] + page = Page.new(target_id, @browser) + push(target_id, page) + target_id + end + + def close_window(target_id) + remove_page(target_id)&.close + end + + def within_window(locator) + original = window_handle + + if window_handles.include?(locator) + switch_to_window(locator) + yield + else + raise NoSuchWindowError + end + ensure + switch_to_window(original) + end + + def reset + if @page + @page.close + @browser.command("Target.disposeBrowserContext", browserContextId: @_context_id) + end + + @page = nil + @targets = {} + @_context_id = nil + + @_context_id = @browser.command("Target.createBrowserContext")["browserContextId"] + target_id = @browser.command("Target.createTarget", url: "about:blank", browserContextId: @_context_id)["targetId"] + @page = Page.new(target_id, @browser) + push(target_id, @page) + end + + private + + def find_or_create_page(target_id) + page = @targets[target_id] + page ||= Page.new(target_id, @browser) + @targets[target_id] ||= page + page + end + + def remove_page(target_id) + page = @targets.delete(target_id) + @page = nil if page && @page == page + page + end + + def targets + attempts = 1 + begin + # Targets cannot be empty the must be at least one default target. + targets = @browser.command("Target.getTargets")["targetInfos"] + raise EmptyTargetsError.new("No target browser available") if targets.empty? + targets + rescue EmptyTargetsError + raise if attempts > TARGETS_RETRY_ATTEMPTS + attempts += 1 + sleep TARGETS_RETRY_WAIT + retry + end + end + + def default?(target) + @_default == target["targetId"] + end + + def has?(target) + @targets.key?(target["targetId"]) + end + end +end diff --git a/lib/ferrum/version.rb b/lib/ferrum/version.rb new file mode 100644 index 00000000..877f905d --- /dev/null +++ b/lib/ferrum/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Ferrum + VERSION = "0.1.0" +end diff --git a/spec/browser/basic_auth_spec.rb b/spec/browser/basic_auth_spec.rb new file mode 100644 index 00000000..83ca8066 --- /dev/null +++ b/spec/browser/basic_auth_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Ferrum + describe "basic http authentication" do + let!(:browser) { Browser.new(base_url: @server.base_url) } + + after { browser.reset } + + it "denies without credentials" do + browser.goto("/ferrum/basic_auth") + + expect(browser.status).to eq(401) + expect(browser.body).not_to include("Welcome, authenticated client") + end + + it "allows with given credentials" do + browser.authorize("login", "pass") + + browser.goto("/ferrum/basic_auth") + + expect(browser.status).to eq(200) + expect(browser.body).to include("Welcome, authenticated client") + end + + it "allows even overwriting headers" do + browser.authorize("login", "pass") + browser.headers = { "Cuprite" => "true" } + + browser.goto("/ferrum/basic_auth") + + expect(browser.status).to eq(200) + expect(browser.body).to include("Welcome, authenticated client") + end + + it "denies with wrong credentials" do + browser.authorize("user", "pass!") + + browser.goto("/ferrum/basic_auth") + + expect(browser.status).to eq(401) + expect(browser.body).not_to include("Welcome, authenticated client") + end + + it "allows on POST request" do + browser.authorize("login", "pass") + + browser.goto("/ferrum/basic_auth") + browser.at_css(%([type="submit"])).click + + expect(browser.status).to eq(200) + expect(browser.body).to include("Authorized POST request") + end + end +end diff --git a/spec/browser/cookie_spec.rb b/spec/browser/cookie_spec.rb new file mode 100644 index 00000000..e25ab6d6 --- /dev/null +++ b/spec/browser/cookie_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Ferrum + describe "cookies support" do + let!(:browser) { Browser.new(base_url: @server.base_url) } + + after { browser.reset } + + it "returns set cookies" do + browser.goto("/set_cookie") + + cookie = browser.cookies["stealth"] + expect(cookie.name).to eq("stealth") + expect(cookie.value).to eq("test_cookie") + expect(cookie.domain).to eq("127.0.0.1") + expect(cookie.path).to eq("/") + expect(cookie.size).to eq(18) + expect(cookie.secure?).to be false + expect(cookie.httponly?).to be false + expect(cookie.session?).to be true + expect(cookie.expires).to be_nil + end + + it "can set cookies" do + browser.set_cookie(name: "stealth", value: "omg") + browser.goto("/get_cookie") + expect(browser.body).to include("omg") + end + + it "can set cookies with custom settings" do + browser.set_cookie(name: "stealth", value: "omg", path: "/ferrum") + + browser.goto("/get_cookie") + expect(browser.body).to_not include("omg") + + browser.goto("/ferrum/get_cookie") + expect(browser.body).to include("omg") + + expect(browser.cookies["stealth"].path).to eq("/ferrum") + end + + it "can remove a cookie" do + browser.goto("/set_cookie") + + browser.goto("/get_cookie") + expect(browser.body).to include("test_cookie") + + browser.remove_cookie(name: "stealth") + + browser.goto("/get_cookie") + expect(browser.body).to_not include("test_cookie") + end + + it "can clear cookies" do + browser.goto("/set_cookie") + + browser.goto("/get_cookie") + expect(browser.body).to include("test_cookie") + + browser.clear_cookies + + browser.goto("/get_cookie") + expect(browser.body).to_not include("test_cookie") + end + + it "can set cookies with an expires time" do + time = Time.at(Time.now.to_i + 10000) + browser.goto + browser.set_cookie(name: "foo", value: "bar", expires: time) + expect(browser.cookies["foo"].expires).to eq(time) + end + + it "can set cookies for given domain" do + port = @server.port + browser.set_cookie(name: "stealth", value: "127.0.0.1") + browser.set_cookie(name: "stealth", value: "localhost", domain: "localhost") + + browser.goto("http://localhost:#{port}/ferrum/get_cookie") + expect(browser.body).to include("localhost") + + browser.goto("http://127.0.0.1:#{port}/ferrum/get_cookie") + expect(browser.body).to include("127.0.0.1") + end + + it "sets cookies correctly with :domain option when base_url isn't set" do + begin + browser = Browser.new + browser.set_cookie(name: "stealth", value: "123456", domain: "localhost") + + port = @server.port + browser.goto("http://localhost:#{port}/ferrum/get_cookie") + expect(browser.body).to include("123456") + + browser.goto("http://127.0.0.1:#{port}/ferrum/get_cookie") + expect(browser.body).not_to include("123456") + ensure + browser&.quit + end + end + end +end diff --git a/spec/browser/header_spec.rb b/spec/browser/header_spec.rb new file mode 100644 index 00000000..34dacb45 --- /dev/null +++ b/spec/browser/header_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Ferrum + describe Browser::API::Header do + let!(:browser) { Browser.new(base_url: @server.base_url) } + + after { browser.reset } + + it "allows headers to be set" do + browser.headers = { "Cookie" => "foo=bar", "DV" => "hello" } + browser.goto("/ferrum/headers") + expect(browser.body).to include("COOKIE: foo=bar") + expect(browser.body).to include("DV: hello") + end + + it "allows headers to be read" do + expect(browser.headers).to eq({}) + browser.headers = { "User-Agent" => "Browser", "Host" => "foo.com" } + expect(browser.headers).to eq("User-Agent" => "Browser", "Host" => "foo.com") + end + + it "supports User-Agent" do + browser.headers = { "User-Agent" => "foo" } + browser.goto + expect(browser.evaluate("window.navigator.userAgent")).to eq("foo") + end + + it "sets headers for all HTTP requests" do + browser.headers = { "X-Omg" => "wat" } + browser.goto + browser.execute <<-JS + var request = new XMLHttpRequest(); + request.open("GET", "/ferrum/headers", false); + request.send(); + + if (request.status === 200) { + document.body.innerHTML = request.responseText; + } + JS + expect(browser.body).to include("X_OMG: wat") + end + + it "adds new headers" do + browser.headers = { "User-Agent" => "Browser", "DV" => "hello" } + browser.add_headers("User-Agent" => "Super Browser", "Appended" => "true") + browser.goto("/ferrum/headers") + expect(browser.body).to include("USER_AGENT: Super Browser") + expect(browser.body).to include("DV: hello") + expect(browser.body).to include("APPENDED: true") + end + + it "sets headers on the initial request for referer only" do + browser.headers = { "PermanentA" => "a" } + browser.add_headers("PermanentB" => "b") + browser.add_header({ "Referer" => "http://google.com" }, permanent: false) + browser.add_header({ "TempA" => "a" }, permanent: false) # simply ignored + + browser.goto("/ferrum/headers_with_ajax") + initial_request = browser.at_css("#initial_request").text + ajax_request = browser.at_css("#ajax_request").text + + expect(initial_request).to include("PERMANENTA: a") + expect(initial_request).to include("PERMANENTB: b") + expect(initial_request).to include("REFERER: http://google.com") + expect(initial_request).to include("TEMPA: a") + + expect(ajax_request).to include("PERMANENTA: a") + expect(ajax_request).to include("PERMANENTB: b") + expect(ajax_request).to_not include("REFERER: http://google.com") + expect(ajax_request).to include("TEMPA: a") + end + + it "keeps added headers on redirects" do + browser.add_header({ "X-Custom-Header" => "1" }, permanent: false) + browser.goto("/ferrum/redirect_to_headers") + expect(browser.body).to include("X_CUSTOM_HEADER: 1") + end + + context "multiple windows", skip: true do + it "persists headers across popup windows" do + browser.headers = { + "Cookie" => "foo=bar", + "Host" => "foo.com", + "User-Agent" => "foo" + } + browser.goto("/ferrum/popup_headers") + browser.at_xpath("a[text()='pop up']").click + # browser.click_link("pop up") + browser.switch_to_window browser.windows.last + expect(browser.body).to include("USER_AGENT: foo") + expect(browser.body).to include("COOKIE: foo=bar") + expect(browser.body).to include("HOST: foo.com") + end + + it "sets headers in existing windows" do + browser.open_new_window + browser.headers = { + "Cookie" => "foo=bar", + "Host" => "foo.com", + "User-Agent" => "foo" + } + browser.goto("/ferrum/headers") + expect(browser.body).to include("USER_AGENT: foo") + expect(browser.body).to include("COOKIE: foo=bar") + expect(browser.body).to include("HOST: foo.com") + + browser.switch_to_window browser.windows.last + browser.goto("/ferrum/headers") + expect(browser.body).to include("USER_AGENT: foo") + expect(browser.body).to include("COOKIE: foo=bar") + expect(browser.body).to include("HOST: foo.com") + end + + it "keeps temporary headers local to the current window" do + browser.open_new_window + browser.add_header("X-Custom-Header", "1", permanent: false) + + browser.switch_to_window browser.windows.last + browser.goto("/ferrum/headers") + expect(browser.body).not_to include("X_CUSTOM_HEADER: 1") + + browser.switch_to_window browser.windows.first + browser.goto("/ferrum/headers") + expect(browser.body).to include("X_CUSTOM_HEADER: 1") + end + + it "does not mix temporary headers with permanent ones when propagating to other windows" do + browser.open_new_window + browser.add_header("X-Custom-Header", "1", permanent: false) + browser.add_header("Host", "foo.com") + + browser.switch_to_window browser.windows.last + browser.goto("/ferrum/headers") + expect(browser.body).to include("HOST: foo.com") + expect(browser.body).not_to include("X_CUSTOM_HEADER: 1") + + browser.switch_to_window browser.windows.first + browser.goto("/ferrum/headers") + expect(browser.body).to include("HOST: foo.com") + expect(browser.body).to include("X_CUSTOM_HEADER: 1") + end + + it "does not propagate temporary headers to new windows" do + browser.goto + browser.add_header("X-Custom-Header", "1", permanent: false) + browser.open_new_window + + browser.switch_to_window browser.windows.last + browser.goto("/ferrum/headers") + expect(browser.body).not_to include("X_CUSTOM_HEADER: 1") + + browser.switch_to_window browser.windows.first + browser.goto("/ferrum/headers") + expect(browser.body).to include("X_CUSTOM_HEADER: 1") + end + end + end +end diff --git a/spec/browser/input_spec.rb b/spec/browser/input_spec.rb new file mode 100644 index 00000000..dee8cc8a --- /dev/null +++ b/spec/browser/input_spec.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Ferrum + describe "Browser::API::Input" do + let!(:browser) { Browser.new(base_url: @server.base_url) } + + after { browser.reset } + + context "has ability to send keys", skip: true do + before { browser.goto("/ferrum/send_keys") } + + it "sends keys to empty input" do + input = browser.at_css("#empty_input") + + input.send_keys("Input") + + expect(input.value).to eq("Input") + end + + it "sends keys to filled input" do + input = browser.at_css("#filled_input") + + input.send_keys(" appended") + + expect(input.value).to eq("Text appended") + end + + it "sends keys to empty textarea" do + input = browser.at_css("#empty_textarea") + + input.send_keys("Input") + + expect(input.value).to eq("Input") + end + + it "sends keys to filled textarea" do + input = browser.at_css("#filled_textarea") + + input.send_keys(" appended") + + expect(input.value).to eq("Description appended") + end + + it "sends keys to empty contenteditable div" do + input = browser.at_css("#empty_div") + + input.send_keys("Input") + + expect(input.text).to eq("Input") + end + + it "persists focus across calls" do + input = browser.at_css("#empty_div") + + input.send_keys("helo") + input.send_keys(:Left) + input.send_keys("l") + + expect(input.text).to eq("hello") + end + + it "sends keys to filled contenteditable div" do + input = browser.at_css("#filled_div") + + input.send_keys(" appended") + + expect(input.text).to eq("Content appended") + end + + it "sends sequences" do + input = browser.at_css("#empty_input") + + input.send_keys([:Shift], "S", [:Alt], "t", "r", "i", "g", :Left, "n") + + expect(input.value).to eq("String") + end + + it "submits the form with sequence" do + input = browser.at_css("#without_submit_button input") + + input.send_keys(:Enter) + + expect(input.value).to eq("Submitted") + end + + it "sends sequences with modifiers and letters" do + input = browser.at_css("#empty_input") + + input.send_keys([:Shift, "s"], "t", "r", "i", "n", "g") + + expect(input.value).to eq("String") + end + + it "sends sequences with modifiers and symbols" do + input = browser.at_css("#empty_input") + + keys = Ferrum.mac? ? %i[Alt Left] : %i[Ctrl Left] + + input.send_keys("t", "r", "i", "n", "g", keys, "s") + + expect(input.value).to eq("string") + end + + 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] + + input.send_keys("t", "r", "i", "n", "g", keys, "s") + + expect(input.value).to eq("s") + end + + it "sends modifiers with sequences" do + input = browser.at_css("#empty_input") + + input.send_keys("s", [:Shift, "tring"]) + + expect(input.value).to eq("sTRING") + end + + it "sends modifiers with multiple keys" do + input = browser.at_css("#empty_input") + + input.send_keys("helo", %i[Shift Left Left], "llo") + + expect(input.value).to eq("hello") + end + + it "has an alias" do + input = browser.at_css("#empty_input") + + input.send_key("S") + + expect(input.value).to eq("S") + end + + it "generates correct events with keyCodes for modified puncation" do + input = browser.at_css("#empty_input") + + input.send_keys([:shift, "."], [:shift, "t"]) + + expect(browser.at_css("#key-events-output").text.strip).to eq("keydown:16 keydown:190 keydown:16 keydown:84") + end + + it "suuports snake_case sepcified keys (Capybara standard)" do + input = browser.at_css("#empty_input") + input.send_keys(:PageUp, :page_up) + expect(browser.at_css("#key-events-output").text.strip).to eq("keydown:33 keydown:33") + end + + it "supports :control alias for :Ctrl" do + input = browser.at_css("#empty_input") + input.send_keys([:Ctrl, "a"], [:control, "a"]) + expect(browser.at_css("#key-events-output").text.strip).to eq("keydown:17 keydown:65 keydown:17 keydown:65") + end + + it "supports :command alias for :Meta" do + input = browser.at_css("#empty_input") + input.send_keys([:Meta, "z"], [:command, "z"]) + expect(browser.at_css("#key-events-output").text.strip).to eq("keydown:91 keydown:90 keydown:91 keydown:90") + end + + it "supports Capybara specified numpad keys" do + input = browser.at_css("#empty_input") + input.send_keys(:numpad2, :numpad8, :divide, :decimal) + expect(browser.at_css("#key-events-output").text.strip).to eq("keydown:98 keydown:104 keydown:111 keydown:110") + end + + it "raises error for unknown keys" do + input = browser.at_css("#empty_input") + expect do + input.send_keys("abc", :blah) + end.to raise_error KeyError, "key not found: :blah" + end + end + + context "set", skip: true do + before { browser.goto("/ferrum/set") } + + it "sets a contenteditable's content" do + input = browser.at_css("#filled_div") + input.set("new text") + expect(input.text).to eq("new text") + end + + it "sets multiple contenteditables' content" do + input = browser.at_css("#empty_div") + input.set("new text") + + expect(input.text).to eq("new text") + + input = browser.at_css("#filled_div") + input.set("replacement text") + + expect(input.text).to eq("replacement text") + end + + it "sets a content editable childs content" do + browser.goto("/orig_with_js") + browser.at_css("#existing_content_editable_child").set("WYSIWYG") + expect(browser.at_css("#existing_content_editable_child").text).to eq("WYSIWYG") + end + + describe "events" do + let(:input) { browser.at_css("#input") } + let(:output) { browser.at_css("#output") } + + before { browser.goto("/ferrum/input_events") } + + it "calls event handlers in the correct order" do + input.set("a") + expect(output.text).to eq("keydown keypress input keyup change") + expect(input.value).to eq("a") + end + + it "respects preventDefault() calls in keydown handlers" do + browser.execute "input.addEventListener('keydown', e => e.preventDefault())" + input.set("a") + expect(output.text).to eq("keydown keyup") + expect(input.value).to be_empty + end + + it "respects preventDefault() calls in keypress handlers" do + browser.execute "input.addEventListener('keypress', e => e.preventDefault())" + input.set("a") + expect(output.text).to eq("keydown keypress keyup") + expect(input.value).to be_empty + end + + it "calls event handlers for each character input" do + input.set("abc") + expect(output.text).to eq((["keydown keypress input keyup"] * 3).join(" ") + " change") + expect(input.value).to eq("abc") + end + + it "doesn't call the change event if there is no change" do + input.set("a") + input.set("a") + expect(output.text).to eq("keydown keypress input keyup change keydown keypress input keyup") + end + end + end + + context "date_fields", skip: true do + before { browser.goto("/ferrum/date_fields") } + + it "sets a date" do + input = browser.at_css("#date_field") + + input.set("2016-02-14") + + expect(input.value).to eq("2016-02-14") + end + + it "fills a date" do + browser.fill_in "date_field", with: "2016-02-14" + + expect(browser.at_css("#date_field").value).to eq("2016-02-14") + end + end + end +end diff --git a/spec/browser/intercept_spec.rb b/spec/browser/intercept_spec.rb new file mode 100644 index 00000000..e71fbc19 --- /dev/null +++ b/spec/browser/intercept_spec.rb @@ -0,0 +1,135 @@ +# context "blacklisting urls for resource requests" do +# it "blocks unwanted urls" do +# @driver.browser.url_blacklist = ["unwanted"] +# +# browser "/ferrum/url_blacklist" +# +# expect(@session.status_code).to eq(200) +# expect(@session).to have_content("We are loading some unwanted action here") +# @session.within_frame "framename" do +# expect(@session.html).not_to include("We shouldn't see this.") +# end +# end +# +# it "supports wildcards" do +# @driver.browser.url_blacklist = ["*wanted"] +# +# browser "/ferrum/url_whitelist" +# +# expect(@session.status_code).to eq(200) +# expect(@session).to have_content("We are loading some wanted action here") +# @session.within_frame "framename" do +# expect(@session).not_to have_content("We should see this.") +# end +# @session.within_frame "unwantedframe" do +# expect(@session).not_to have_content("We shouldn't see this.") +# end +# end +# +# it "can be configured in the driver and survive reset" do +# Capybara.register_driver :cuprite_blacklist do |app| +# Capybara::Cuprite::Driver.new(app, @driver.options.merge(url_blacklist: ["unwanted"])) +# end +# +# session = Capybara::Session.new(:cuprite_blacklist, @session.app) +# +# session.visit "/ferrum/url_blacklist" +# expect(session).to have_content("We are loading some unwanted action here") +# session.within_frame "framename" do +# expect(session.html).not_to include("We shouldn't see this.") +# end +# +# session.reset! +# +# session.visit "/ferrum/url_blacklist" +# expect(session).to have_content("We are loading some unwanted action here") +# session.within_frame "framename" do +# expect(session.html).not_to include("We shouldn't see this.") +# end +# end +# end +# +# context "whitelisting urls for resource requests" do +# it "allows whitelisted urls" do +# @driver.browser.url_whitelist = ["url_whitelist", "/wanted"] +# +# browser "/ferrum/url_whitelist" +# +# expect(@session.status_code).to eq(200) +# expect(@session).to have_content("We are loading some wanted action here") +# @session.within_frame "framename" do +# expect(@session).to have_content("We should see this.") +# end +# @session.within_frame "unwantedframe" do +# expect(@session).not_to have_content("We shouldn't see this.") +# end +# end +# +# it "supports wildcards" do +# @driver.browser.url_whitelist = ["url_whitelist", "/*wanted"] +# +# browser "/ferrum/url_whitelist" +# +# expect(@session.status_code).to eq(200) +# expect(@session).to have_content("We are loading some wanted action here") +# @session.within_frame "framename" do +# expect(@session).to have_content("We should see this.") +# end +# @session.within_frame "unwantedframe" do +# expect(@session).to have_content("We shouldn't see this.") +# end +# end +# +# it "blocks overruled urls" do +# @driver.browser.url_whitelist = ["url_whitelist"] +# @driver.browser.url_blacklist = ["url_whitelist"] +# +# browser "/ferrum/url_whitelist" +# +# expect(@session.status_code).to eq(nil) +# expect(@session).not_to have_content("We are loading some wanted action here") +# end +# +# it "allows urls when the whitelist is empty" do +# @driver.browser.url_whitelist = [] +# +# browser "/ferrum/url_whitelist" +# +# expect(@session.status_code).to eq(200) +# expect(@session).to have_content("We are loading some wanted action here") +# @session.within_frame "framename" do +# expect(@session).to have_content("We should see this.") +# end +# end +# +# it "can be configured in the driver and survive reset" do +# Capybara.register_driver :cuprite_whitelist do |app| +# Capybara::Cuprite::Driver.new(app, @driver.options.merge(url_whitelist: ["url_whitelist", "/ferrum/wanted"])) +# end +# +# session = Capybara::Session.new(:cuprite_whitelist, @session.app) +# +# session.visit "/ferrum/url_whitelist" +# expect(session).to have_content("We are loading some wanted action here") +# session.within_frame "framename" do +# expect(session).to have_content("We should see this.") +# end +# +# session.within_frame "unwantedframe" do +# # make sure non whitelisted urls are blocked +# expect(session).not_to have_content("We shouldn't see this.") +# end +# +# session.reset! +# +# session.visit "/ferrum/url_whitelist" +# expect(session).to have_content("We are loading some wanted action here") +# session.within_frame "framename" do +# expect(session).to have_content("We should see this.") +# end +# session.within_frame "unwantedframe" do +# # make sure non whitelisted urls are blocked +# expect(session).not_to have_content("We shouldn't see this.") +# end +# end +# end diff --git a/spec/browser/network_traffic_spec.rb b/spec/browser/network_traffic_spec.rb new file mode 100644 index 00000000..e333bbf7 --- /dev/null +++ b/spec/browser/network_traffic_spec.rb @@ -0,0 +1,89 @@ +# context "network traffic" do +# it "keeps track of network traffic" do +# browser("/ferrum/with_js") +# urls = @driver.network_traffic.map(&:url) +# +# expect(urls.grep(%r{/ferrum/jquery.min.js$}).size).to eq(1) +# expect(urls.grep(%r{/ferrum/jquery-ui.min.js$}).size).to eq(1) +# expect(urls.grep(%r{/ferrum/test.js$}).size).to eq(1) +# end +# +# it "keeps track of blocked network traffic" do +# @driver.browser.url_blacklist = ["unwanted"] +# +# browser "/ferrum/url_blacklist" +# +# blocked_urls = @driver.network_traffic(:blocked).map(&:url) +# +# expect(blocked_urls).to include(/unwanted/) +# end +# +# it "captures responses" do +# browser("/ferrum/with_js") +# request = @driver.network_traffic.last +# +# expect(request.response.status).to eq(200) +# end +# +# it "captures errors" do +# browser("/ferrum/with_ajax_fail") +# expect(@session).to have_css("h1", text: "Done") +# error = @driver.network_traffic.last.error +# +# expect(error).to be +# end +# +# it "keeps a running list between multiple web page views" do +# browser("/ferrum/with_js") +# expect(@driver.network_traffic.length).to eq(4) +# +# browser("/ferrum/with_js") +# expect(@driver.network_traffic.length).to eq(8) +# end +# +# it "gets cleared on restart" do +# browser("/ferrum/with_js") +# expect(@driver.network_traffic.length).to eq(4) +# +# @driver.restart +# +# browser("/ferrum/with_js") +# expect(@driver.network_traffic.length).to eq(4) +# end +# +# it "gets cleared when being cleared" do +# browser("/ferrum/with_js") +# expect(@driver.network_traffic.length).to eq(4) +# +# @driver.clear_network_traffic +# +# expect(@driver.network_traffic.length).to eq(0) +# end +# +# it "blocked requests get cleared along with network traffic" do +# @driver.browser.url_blacklist = ["unwanted"] +# +# browser "/ferrum/url_blacklist" +# +# expect(@driver.network_traffic(:blocked).length).to eq(3) +# +# @driver.clear_network_traffic +# +# expect(@driver.network_traffic(:blocked).length).to eq(0) +# end +# +# it "counts network traffic for each loaded resource" do +# browser("/ferrum/with_js") +# responses = @driver.network_traffic.map(&:response) +# resources_size = { +# %r{/ferrum/jquery.min.js$} => File.size(PROJECT_ROOT + "/spec/support/public/jquery-1.11.3.min.js"), +# %r{/ferrum/jquery-ui.min.js$} => File.size(PROJECT_ROOT + "/spec/support/public/jquery-ui-1.11.4.min.js"), +# %r{/ferrum/test.js$} => File.size(PROJECT_ROOT + "/spec/support/public/test.js"), +# %r{/ferrum/with_js$} => 2329 +# } +# +# resources_size.each do |resource, size| +# expect(responses.find { |r| r.url[resource] }.body_size).to eq(size) +# end +# end +# end diff --git a/spec/browser/screenshot_spec.rb b/spec/browser/screenshot_spec.rb new file mode 100644 index 00000000..3586a304 --- /dev/null +++ b/spec/browser/screenshot_spec.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require "image_size" +require "pdf/reader" +require "chunky_png" +require "spec_helper" + +module Ferrum + describe Browser::API::Screenshot do + let!(:browser) { Browser.new(base_url: @server.base_url) } + + after { browser.reset } + + shared_examples "screenshot screen" do + it "supports screenshotting the whole of a page that goes outside the viewport" do + browser.goto("/ferrum/long_page") + + create_screenshot(path: file) + + File.open(file, "rb") do |f| + expect(ImageSize.new(f.read).size).to eq( + browser.evaluate("[window.innerWidth, window.innerHeight]") + ) + end + + create_screenshot(path: file, full: true) + + File.open(file, "rb") do |f| + expect(ImageSize.new(f.read).size).to eq( + browser.evaluate("[document.documentElement.clientWidth, document.documentElement.clientHeight]") + ) + end + end + + it "supports screenshotting the entire window when documentElement has no height" do + browser.goto("/ferrum/fixed_positioning") + + create_screenshot(path: file, full: true) + + File.open(file, "rb") do |f| + expect(ImageSize.new(f.read).size).to eq( + browser.evaluate("[window.innerWidth, window.innerHeight]") + ) + end + end + + it "supports screenshotting just the selected element" do + browser.goto("/ferrum/long_page") + + create_screenshot(path: file, selector: "#penultimate") + + File.open(file, "rb") do |f| + size = browser.evaluate <<-JS + function() { + var ele = document.getElementById("penultimate"); + var rect = ele.getBoundingClientRect(); + return [rect.width, rect.height]; + }(); + JS + expect(ImageSize.new(f.read).size).to eq(size) + end + end + + it "ignores :selector in #save_screenshot if full: true" do + browser.goto("/ferrum/long_page") + expect(browser).to receive(:warn).with(/Ignoring :selector/) + + create_screenshot(path: file, full: true, selector: "#penultimate") + + File.open(file, "rb") do |f| + expect(ImageSize.new(f.read).size).to eq( + browser.evaluate("[document.documentElement.clientWidth, document.documentElement.clientHeight]") + ) + end + end + + it "resets element positions after" do + browser.goto("ferrum/long_page") + el = browser.at_css("#middleish") + # make the page scroll an element into view + el.click + position_script = "document.querySelector('#middleish').getBoundingClientRect()" + offset = browser.evaluate(position_script) + browser.screenshot(path: file) + expect(browser.evaluate(position_script)).to eq offset + end + end + + describe "#screenshot" do + let(:format) { :png } + let(:file) { "#{PROJECT_ROOT}/spec/tmp/screenshot.#{format}" } + + def create_screenshot(**options) + browser.screenshot(**options) + end + + after do + FileUtils.rm_f("#{PROJECT_ROOT}/spec/tmp/screenshot.pdf") + FileUtils.rm_f("#{PROJECT_ROOT}/spec/tmp/screenshot.png") + end + + it "supports screenshotting the page" do + browser.goto + + browser.screenshot(path: file) + + expect(File.exist?(file)).to be true + end + + it "supports screenshotting the page with a nonstring path" do + browser.goto + + browser.screenshot(path: Pathname(file)) + + expect(File.exist?(file)).to be true + end + + it "supports screenshotting the page to file without extension when format is specified" do + begin + file = PROJECT_ROOT + "/spec/tmp/screenshot" + browser.goto + + browser.screenshot(path: file, format: "jpg") + + expect(File.exist?(file)).to be true + ensure + FileUtils.rm_f(file) + end + end + + it "supports screenshotting the page with different quality settings" do + file2 = PROJECT_ROOT + "/spec/tmp/screenshot2.jpeg" + file3 = PROJECT_ROOT + "/spec/tmp/screenshot3.jpeg" + FileUtils.rm_f([file2, file3]) + + begin + browser.goto + browser.screenshot(path: file, quality: 0) # ignored for png + browser.screenshot(path: file2) # defaults to a quality of 75 + browser.screenshot(path: file3, quality: 100) + expect(File.size(file)).to be > File.size(file2) # png by defult is bigger + expect(File.size(file2)).to be < File.size(file3) + ensure + FileUtils.rm_f([file2, file3]) + end + end + + shared_examples "when #zoom_factor= is set" do + it "changes image dimensions" do + browser.goto("/ferrum/zoom_test") + + black_pixels_count = lambda { |file| + img = ChunkyPNG::Image.from_file(file) + img.pixels.inject(0) { |i, p| p > 255 ? i + 1 : i } + } + + browser.screenshot(path: file) + before = black_pixels_count[file] + + browser.zoom_factor = zoom_factor + browser.screenshot(path: file) + after = black_pixels_count[file] + + expect(after.to_f / before.to_f).to eq(zoom_factor**2) + end + end + + context "zoom in" do + let(:zoom_factor) { 2 } + include_examples "when #zoom_factor= is set" + end + + context "zoom out" do + let(:zoom_factor) { 0.5 } + include_examples "when #zoom_factor= is set" + end + + context "when #paper_size= is set" do + let(:format) { :pdf } + + it "changes pdf size" do + browser.goto("/ferrum/long_page") + browser.paper_size = { width: "1in", height: "1in" } + + browser.screenshot(path: file) + + reader = PDF::Reader.new(file) + reader.pages.each do |page| + bbox = page.attributes[:MediaBox] + width = (bbox[2] - bbox[0]) / 72 + expect(width).to eq(1) + end + end + end + + include_examples "screenshot screen" + + context "when encoding is base64" do + let(:file) { "#{PROJECT_ROOT}/spec/tmp/screenshot.#{format}" } + + def create_screenshot(path: file, **options) + image = browser.screenshot(format: format, encoding: :base64, **options) + File.open(file, "wb") { |f| f.write Base64.decode64(image) } + end + + it "defaults to base64 when path isn't set" do + browser.goto + + screenshot = browser.screenshot(format: format) + + expect(screenshot.length).to be > 100 + end + + it "supports screenshotting the page in base64" do + browser.goto + + screenshot = browser.screenshot(encoding: :base64) + + expect(screenshot.length).to be > 100 + end + + context "png" do + let(:format) { :png } + after { FileUtils.rm_f(file) } + + include_examples "screenshot screen" + end + + context "jpeg" do + let(:format) { :jpeg } + after { FileUtils.rm_f(file) } + + include_examples "screenshot screen" + end + end + end + end +end diff --git a/spec/browser_spec.rb b/spec/browser_spec.rb new file mode 100644 index 00000000..3c30e8a4 --- /dev/null +++ b/spec/browser_spec.rb @@ -0,0 +1,537 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Ferrum + describe Browser do + let!(:browser) { Browser.new(base_url: @server.base_url) } + + after { browser.reset } + + it "supports a custom path" do + begin + original_path = PROJECT_ROOT + "/spec/support/chrome_path" + File.write(original_path, browser.process.path) + + file = PROJECT_ROOT + "/spec/support/custom_chrome_called" + path = PROJECT_ROOT + "/spec/support/custom_chrome" + + browser = Browser.new(browser_path: path) + + # If the correct custom path is called, it will touch the file. + # We allow at least 10 secs for this to happen before failing. + + tries = 0 + until File.exist?(file) || tries == 100 + sleep 0.1 + tries += 1 + end + + expect(File.exist?(file)).to be true + ensure + FileUtils.rm_f(original_path) + FileUtils.rm_f(file) + browser&.quit + end + end + + context "output redirection" do + let(:logger) { StringIO.new } + + it "supports capturing console.log" do + begin + browser = Browser.new(logger: logger) + browser.goto(base_url("/ferrum/console_log")) + expect(logger.string).to include("Hello world") + ensure + browser&.quit + end + end + end + + it "raises an error and restarts the client if the client dies while executing a command" do + expect { browser.crash }.to raise_error(Ferrum::DeadBrowser) + browser.goto + expect(browser.body).to include("Hello world") + end + + it "stops silently before goto call" do + browser = Browser.new + expect { browser.quit }.not_to raise_error + end + + it "has a viewport size of 1024x768 by default" do + browser.goto + + expect( + browser.evaluate("[window.innerWidth, window.innerHeight]") + ).to eq([1024, 768]) + end + + it "allows the viewport to be resized" do + browser.goto + browser.resize(width: 200, height: 400) + expect( + browser.evaluate("[window.innerWidth, window.innerHeight]") + ).to eq([200, 400]) + end + + # it "defaults viewport maximization to 1366x768" do + # browser.goto + # browser.current_window.maximize + # expect(browser.current_window.size).to eq([1366, 768]) + # end + + # it "allows custom maximization size" do + # begin + # browser.options[:screen_size] = [1600, 1200] + # browser.goto + # browser.current_window.maximize + # expect(browser.current_window.size).to eq([1600, 1200]) + # ensure + # browser.options.delete(:screen_size) + # end + # end + + it "allows the page to be scrolled" do + browser.goto("/ferrum/long_page") + browser.resize(width: 10, height: 10) + browser.scroll_to(200, 100) + expect( + browser.evaluate("[window.scrollX, window.scrollY]") + ).to eq([200, 100]) + end + + it "supports specifying viewport size with an option" do + begin + browser = Browser.new(window_size: [800, 600]) + browser.goto(base_url) + expect( + browser.evaluate("[window.innerWidth, window.innerHeight]") + ).to eq([800, 600]) + ensure + browser&.quit + end + end + + it "supports clicking precise coordinates" do + browser.goto("/ferrum/click_coordinates") + browser.click_coordinates(100, 150) + expect(browser.body).to include("x: 100, y: 150") + end + + it "supports executing multiple lines of javascript" do + browser.execute <<-JS + var a = 1 + var b = 2 + window.result = a + b + JS + expect(browser.evaluate("window.result")).to eq(3) + end + + it "operates a timeout when communicating with browser", skip: true do + begin + prev_timeout = browser.timeout + browser.timeout = 0.1 + expect { browser.goto("/ferrum/really_slow") }.to raise_error(TimeoutError) + ensure + browser.timeout = prev_timeout + end + end + + it "supports stopping the session", skip: Ferrum.windows? do + browser = Browser.new + pid = browser.process.pid + + expect(Process.kill(0, pid)).to eq(1) + browser.quit + + expect { Process.kill(0, pid) }.to raise_error(Errno::ESRCH) + end + + context "extending browser javascript" do + it "supports extending the browser's world" do + begin + browser = Browser.new(base_url: @server.base_url, + extensions: [File.expand_path("support/geolocation.js", __dir__)]) + + browser.goto("/ferrum/requiring_custom_extension") + + expect( + browser.body + ).to include(%(Location: 1,-1)) + + expect( + browser.evaluate(%(document.getElementById("location").innerHTML)) + ).to eq("1,-1") + + expect( + browser.evaluate("navigator.geolocation") + ).to_not eq(nil) + ensure + browser&.quit + end + end + + it "errors when extension is unavailable" do + begin + browser = Browser.new(extensions: [File.expand_path("../support/non_existent.js", __dir__)]) + expect { browser.goto }.to raise_error(Errno::ENOENT) + ensure + browser&.quit + end + end + end + + context "javascript errors" do + let(:browser) { Browser.new(base_url: @server.base_url, js_errors: true) } + + it "propagates a Javascript error to a ruby exception" do + expect do + browser.execute(%(throw new Error("zomg"))) + end.to raise_error(Ferrum::JavaScriptError) { |e| + expect(e.message).to include("Error: zomg") + } + end + + it "propagates an asynchronous Javascript error on the page to a ruby exception" do + expect do + browser.execute "setTimeout(function() { omg }, 0)" + sleep 0.01 + browser.execute "" + end.to raise_error(Ferrum::JavaScriptError, /ReferenceError.*omg/) + end + + it "propagates a synchronous Javascript error on the page to a ruby exception" do + expect do + browser.execute "omg" + end.to raise_error(Ferrum::JavaScriptError, /ReferenceError.*omg/) + end + + it "does not re-raise a Javascript error if it is rescued" do + expect do + browser.execute "setTimeout(function() { omg }, 0)" + sleep 0.01 + browser.execute "" + end.to raise_error(Ferrum::JavaScriptError) + + # should not raise again + expect(browser.evaluate("1+1")).to eq(2) + end + + it "propagates a Javascript error during page load to a ruby exception" do + expect { browser.goto("/ferrum/js_error") }.to raise_error(Ferrum::JavaScriptError) + end + + it "does not propagate a Javascript error to ruby if error raising disabled" do + begin + browser = Browser.new(base_url: @server.base_url, js_errors: false) + browser.goto("/ferrum/js_error") + browser.execute "setTimeout(function() { omg }, 0)" + sleep 0.1 + expect(browser.body).to include("hello") + ensure + browser&.quit + end + end + + it "does not propagate a Javascript error to ruby if error raising disabled and client restarted" do + begin + browser = Browser.new(base_url: @server.base_url, js_errors: false) + browser.restart + browser.goto("/ferrum/js_error") + browser.execute "setTimeout(function() { omg }, 0)" + sleep 0.1 + expect(browser.body).to include("hello") + ensure + browser&.quit + end + end + end + + context "browser failed responses" do + let(:port) { @server.port } + + it "do not occur when DNS correct" do + expect { browser.goto("http://localhost:#{port}/") }.not_to raise_error + end + + it "handles when DNS incorrect" do + expect { browser.goto("http://nope:#{port}/") }.to raise_error(Ferrum::StatusFailError) + end + + it "has a descriptive message when DNS incorrect" do + url = "http://nope:#{port}/" + expect { browser.goto(url) } + .to raise_error( + Ferrum::StatusFailError, + %(Request to #{url} failed to reach server, check DNS and/or server status) + ) + end + + it "reports open resource requests", skip: true do + old_timeout = browser.timeout + begin + browser.timeout = 2 + expect do + browser.goto("/ferrum/visit_timeout") + end.to raise_error(Ferrum::StatusFailError, %r{resources still waiting http://.*/ferrum/really_slow}) + ensure + browser.timeout = old_timeout + end + end + + it "does not report open resources where there are none", skip: true do + old_timeout = browser.timeout + begin + browser.timeout = 2 + expect do + browser.goto("/ferrum/really_slow") + end.to raise_error(Ferrum::StatusFailError) { |error| + expect(error.message).not_to include("resources still waiting") + } + ensure + browser.timeout = old_timeout + end + end + end + + it "can clear memory cache" do + browser.clear_memory_cache + + browser.goto("/ferrum/cacheable") + first_request = browser.network_traffic.last + expect(browser.network_traffic.length).to eq(1) + expect(first_request.response.status).to eq(200) + + browser.refresh + expect(browser.network_traffic.length).to eq(2) + expect(browser.network_traffic.last.response.status).to eq(304) + + browser.clear_memory_cache + + browser.refresh + another_request = browser.network_traffic.last + expect(browser.network_traffic.length).to eq(3) + expect(another_request.response.status).to eq(200) + end + + context "status code support" do + it "determines status from the simple response" do + browser.goto("/ferrum/status/500") + expect(browser.status).to eq(500) + end + + it "determines status code when the page has a few resources" do + browser.goto("/ferrum/with_different_resources") + expect(browser.status).to eq(200) + end + + it "determines status code even after redirect" do + browser.goto("/ferrum/redirect") + expect(browser.status).to eq(200) + end + end + + it "allows the driver to have a fixed port" do + begin + browser = Browser.new(port: 12345) + browser.goto(base_url) + + expect { TCPServer.new("127.0.0.1", 12345) }.to raise_error(Errno::EADDRINUSE) + ensure + browser&.quit + end + end + + it "allows the driver to run tests on external process" do + with_external_browser do |url| + begin + browser = Browser.new(url: url) + browser.goto(base_url) + expect(browser.body).to include("Hello world!") + ensure + browser&.quit + end + end + end + + it "allows the driver to have a custom host" do + begin + # Use custom host "pointing" to localhost, specified by BROWSER_TEST_HOST env var. + # Use /etc/hosts or iptables for this: https://superuser.com/questions/516208/how-to-change-ip-address-to-point-to-localhost + host = ENV["BROWSER_TEST_HOST"] + + skip "BROWSER_TEST_HOST not set" if host.nil? # skip test if var is unspecified + + browser = Browser.new(host: host, port: 12345) + browser.goto(base_url) + + expect { TCPServer.new(host, 12345) }.to raise_error(Errno::EADDRINUSE) + ensure + browser&.quit + end + end + + # it "lists the open windows" do + # browser.goto + # + # browser.execute <<-JS + # window.open("/ferrum/simple", "popup") + # JS + # + # expect(browser.window_handles.size).to eq(2) + # + # popup2 = browser.window_opened_by do + # browser.execute <<-JS + # window.open("/ferrum/simple", "popup2") + # JS + # end + # + # expect(browser.window_handles.size).to eq(3) + # + # browser.within_window(popup2) do + # expect(browser.body).to include("Test") + # browser.execute("window.close()") + # end + # + # sleep 0.1 + # + # expect(browser.window_handles.size).to eq(2) + # end + # + # context "a new window inherits settings" do + # it "inherits size" do + # browser.goto + # browser.current_window.resize_to(1200, 800) + # new_tab = browser.open_new_window + # expect(new_tab.size).to eq [1200, 800] + # end + # + # it "inherits url_blacklist" do + # @driver.browser.url_blacklist = ["unwanted"] + # @session.goto + # new_tab = @session.open_new_window + # @session.within_window(new_tab) do + # @session.goto "/ferrum/url_blacklist" + # expect(@session).to have_content("We are loading some unwanted action here") + # @session.within_frame "framename" do + # expect(@session.html).not_to include("We shouldn't see this.") + # end + # end + # end + # + # it "inherits url_whitelist" do + # @session.goto + # @driver.browser.url_whitelist = ["url_whitelist", "/ferrum/wanted"] + # new_tab = @session.open_new_window + # @session.within_window(new_tab) do + # @session.goto "/ferrum/url_whitelist" + # + # expect(@session).to have_content("We are loading some wanted action here") + # @session.within_frame "framename" do + # expect(@session).to have_content("We should see this.") + # end + # @session.within_frame "unwantedframe" do + # # make sure non whitelisted urls are blocked + # expect(@session).not_to have_content("We shouldn't see this.") + # end + # end + # end + # end + # + # it "resizes windows" do + # @session.goto + # + # popup1 = @session.window_opened_by do + # @session.execute_script <<-JS + # window.open("/ferrum/simple", "popup1") + # JS + # end + # + # popup2 = @session.window_opened_by do + # @session.execute_script <<-JS + # window.open("/ferrum/simple", "popup2") + # JS + # end + # + # popup1.resize_to(100, 200) + # popup2.resize_to(200, 100) + # + # expect(popup1.size).to eq([100, 200]) + # expect(popup2.size).to eq([200, 100]) + # end + + it "clears local storage after reset" do + browser.goto + browser.execute <<~JS + localStorage.setItem("key", "value"); + JS + value = browser.evaluate <<~JS + localStorage.getItem("key"); + JS + + expect(value).to eq("value") + + browser.reset + + browser.goto + value = browser.evaluate <<~JS + localStorage.getItem("key"); + JS + expect(value).to be_nil + end + + context "evaluate" do + it "can return an element" do + browser.goto("/ferrum/send_keys") + element = browser.evaluate(%(document.getElementById("empty_input"))) + expect(element).to eq(browser.at_css("#empty_input")) + end + + it "can return structures with elements" do + browser.goto("/ferrum/send_keys") + result = browser.evaluate <<~JS + { + a: document.getElementById("empty_input"), + b: { c: document.querySelectorAll("#empty_textarea, #filled_textarea") } + } + JS + + expect(result).to eq( + "a" => browser.at_css("#empty_input"), + "b" => { + "c" => browser.css("#empty_textarea, #filled_textarea") + } + ) + end + end + + context "evaluate_async" do + it "handles evaluate_async value properly" do + expect(browser.evaluate_async("arguments[0](null)", 5)).to be_nil + expect(browser.evaluate_async("arguments[0](false)", 5)).to be false + expect(browser.evaluate_async("arguments[0](true)", 5)).to be true + expect(browser.evaluate_async(%(arguments[0]({foo: "bar"})), 5)).to eq("foo" => "bar") + end + + it "will timeout" do + expect do + browser.evaluate_async("var callback=arguments[0]; setTimeout(function(){callback(true)}, 4000)", 1) + end.to raise_error Ferrum::ScriptTimeoutError + end + end + + # it "can get the frames url" do + # browser.goto("/ferrum/frames") + # + # browser.within_frame(0) do + # expect(browser.frame_url).to end_with("/ferrum/slow") + # expect(browser.current_url).to end_with("/ferrum/frames") + # end + # + # expect(browser.frame_url).to end_with("/ferrum/frames") + # expect(browser.current_url).to end_with("/ferrum/frames") + # end + end +end diff --git a/spec/session.rb b/spec/session.rb new file mode 100644 index 00000000..171c8585 --- /dev/null +++ b/spec/session.rb @@ -0,0 +1,1017 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Session" do + context "with driver" do + describe Ferrum::Node do + it "raises an error if the element has been removed from the DOM" do + browser("/ferrum/with_js") + node = @session.find(:css, "#remove_me") + expect(node.text).to eq("Remove me") + @session.find(:css, "#remove").click + expect { node.text }.to raise_error(Ferrum::ObsoleteNode) + end + + it "raises an error if the element was on a previous page" do + browser("/ferrum/index") + node = @session.find(".//a") + @session.execute_script "window.location = 'about:blank'" + expect { node.text }.to raise_error(Ferrum::ObsoleteNode) + end + + it "raises an error if the element is not visible" do + browser("/ferrum/index") + @session.execute_script %(document.querySelector("a[href=js_redirect]").style.display = "none") + expect { @session.click_link "JS redirect" }.to raise_error(Ferrum::ElementNotFound) + end + + it "hovers an element" do + browser("/ferrum/with_js") + expect(@session.find(:css, "#hidden_link span", visible: false)).to_not be_visible + @session.find(:css, "#hidden_link").hover + expect(@session.find(:css, "#hidden_link span")).to be_visible + end + + it "hovers an element before clicking it" do + browser("/ferrum/with_js") + @session.click_link "Hidden link" + expect(@session.current_path).to eq("/") + end + + it "does not raise error when asserting svg elements with a count that is not what is in the dom" do + browser("/ferrum/with_js") + expect { @session.has_css?("svg circle", count: 2) }.to_not raise_error + expect(@session).to_not have_css("svg circle", count: 2) + end + + context "when someone (*cough* prototype *cough*) messes with Array#toJSON" do + before do + browser("/ferrum/index") + array_munge = <<-JS + Array.prototype.toJSON = function() { + return "ohai"; + } + JS + @session.execute_script array_munge + end + + it "gives a proper error" do + expect { @session.find(:css, "username") }.to raise_error(Ferrum::ElementNotFound) + end + end + + context "when someone messes with JSON" do + # mootools <= 1.2.4 replaced the native JSON with it's own JSON that didn't have stringify or parse methods + it "works correctly" do + browser("/ferrum/index") + @session.execute_script("JSON = {};") + expect { @session.find(:link, "JS redirect") }.not_to raise_error + end + end + + context "when the element is not in the viewport" do + before do + browser("/ferrum/with_js") + end + + it "raises a MouseEventFailed error" do + expect { @session.click_link("O hai") } + .to raise_error(Ferrum::MouseEventFailed) + end + + context "and is then brought in" do + before do + @session.execute_script %Q($("#off-the-left").animate({left: "10"});) + end + + it "clicks properly" do + expect { @session.click_link "O hai" }.to_not raise_error + end + end + end + end + + context "when the element is not in the viewport of parent element" do + before do + browser("/ferrum/scroll") + end + + it "scrolls into view" do + @session.click_link "Link outside viewport" + expect(@session.current_path).to eq("/") + end + + it "scrolls into view if scrollIntoViewIfNeeded fails" do + @session.click_link "Below the fold" + expect(@session.current_path).to eq("/") + end + end + + describe "Node#select" do + before do + browser("/ferrum/with_js") + end + + context "when selected option is not in optgroup" do + before do + @session.find(:select, "browser").find(:option, "Firefox").select_option + end + + it "fires the focus event" do + expect(@session.find(:css, "#changes_on_focus").text).to eq("Browser") + end + + it "fire the change event" do + expect(@session.find(:css, "#changes").text).to eq("Firefox") + end + + it "fires the blur event" do + expect(@session.find(:css, "#changes_on_blur").text).to eq("Firefox") + end + + it "fires the change event with the correct target" do + expect(@session.find(:css, "#target_on_select").text).to eq("SELECT") + end + end + + context "when selected option is in optgroup" do + before do + @session.find(:select, "browser").find(:option, "Safari").select_option + end + + it "fires the focus event" do + expect(@session.find(:css, "#changes_on_focus").text).to eq("Browser") + end + + it "fire the change event" do + expect(@session.find(:css, "#changes").text).to eq("Safari") + end + + it "fires the blur event" do + expect(@session.find(:css, "#changes_on_blur").text).to eq("Safari") + end + + it "fires the change event with the correct target" do + expect(@session.find(:css, "#target_on_select").text).to eq("SELECT") + end + end + end + + describe "Node#set" do + before do + browser("/ferrum/with_js") + @session.find(:css, "#change_me").set("Hello!") + end + + it "fires the change event" do + expect(@session.find(:css, "#changes").text).to eq("Hello!") + end + + it "fires the input event" do + expect(@session.find(:css, "#changes_on_input").text).to eq("Hello!") + end + + it "accepts numbers in a maxlength field" do + element = @session.find(:css, "#change_me_maxlength") + element.set 100 + expect(element.value).to eq("100") + end + + it "accepts negatives in a number field" do + element = @session.find(:css, "#change_me_number") + element.set(-100) + expect(element.value).to eq("-100") + end + + it "fires the keydown event" do + expect(@session.find(:css, "#changes_on_keydown").text).to eq("6") + end + + it "fires the keyup event" do + expect(@session.find(:css, "#changes_on_keyup").text).to eq("6") + end + + it "fires the keypress event" do + expect(@session.find(:css, "#changes_on_keypress").text).to eq("6") + end + + it "fires the focus event" do + expect(@session.find(:css, "#changes_on_focus").text).to eq("Focus") + end + + it "fires the blur event" do + expect(@session.find(:css, "#changes_on_blur").text).to eq("Blur") + end + + it "fires the keydown event before the value is updated" do + expect(@session.find(:css, "#value_on_keydown").text).to eq("Hello") + end + + it "fires the keyup event after the value is updated" do + expect(@session.find(:css, "#value_on_keyup").text).to eq("Hello!") + end + + it "clears the input before setting the new value" do + element = @session.find(:css, "#change_me") + element.set "" + expect(element.value).to eq("") + end + + it "supports special characters" do + element = @session.find(:css, "#change_me") + element.set "$52.00" + expect(element.value).to eq("$52.00") + end + + it "attaches a file when passed a Pathname" do + begin + filename = Pathname.new("spec/tmp/a_test_pathname").expand_path + File.open(filename, "w") { |f| f.write("text") } + + element = @session.find(:css, "#change_me_file") + element.set(filename) + expect(element.value).to eq("C:\\fakepath\\a_test_pathname") + ensure + FileUtils.rm_f(filename) + end + end + end + + describe "Node#visible" do + before do + browser("/ferrum/visible") + end + + it "considers display: none to not be visible" do + expect(@session.find(:css, "li", text: "Display None", visible: false).visible?).to be false + end + + it "considers visibility: hidden to not be visible" do + expect(@session.find(:css, "li", text: "Hidden", visible: false).visible?).to be false + end + + it "considers opacity: 0 to not be visible" do + expect(@session.find(:css, "li", text: "Transparent", visible: false).visible?).to be false + end + + it "element with all children hidden returns empty text" do + expect(@session.find(:css, "div").text).to eq("") + end + end + + describe "Node#checked?" do + before do + browser "/ferrum/attributes_properties" + end + + it "is a boolean" do + expect(@session.find_field("checked").checked?).to be true + expect(@session.find_field("unchecked").checked?).to be false + end + end + + describe "Node#[]" do + before do + browser "/ferrum/attributes_properties" + end + + it "gets normalized href" do + expect(@session.find(:link, "Loop")["href"]).to eq("http://#{@session.server.host}:#{@session.server.port}/ferrum/attributes_properties") + end + + it "gets innerHTML" do + expect(@session.find(:css, ".some_other_class")["innerHTML"]).to eq "
foobar
" + end + + it "gets attribute" do + link = @session.find(:link, "Loop") + expect(link["data-random"]).to eq "42" + expect(link["onclick"]).to eq "return false;" + end + + it "gets boolean attributes as booleans" do + expect(@session.find_field("checked")["checked"]).to be true + expect(@session.find_field("unchecked")["checked"]).to be false + end + end + + describe "Node#==" do + it "does not equal a node from another page" do + browser("/ferrum/simple") + @elem1 = @session.find(:css, "#nav") + browser("/ferrum/set") + @elem2 = @session.find(:css, "#filled_div") + expect(@elem2 == @elem1).to be false + expect(@elem1 == @elem2).to be false + end + end + + it "has no trouble clicking elements when the size of a document changes" do + browser("/ferrum/long_page") + @session.find(:css, "#penultimate").click + @session.execute_script <<-JS + el = document.getElementById("penultimate") + el.parentNode.removeChild(el) + JS + @session.click_link("Phasellus blandit velit") + expect(@session).to have_content("Hello") + end + + it "handles clicks where the target is in view, but the document is smaller than the viewport" do + browser "/ferrum/simple" + @session.click_link "Link" + expect(@session).to have_content("Hello world") + end + + it "handles clicks where a parent element has a border" do + browser "/ferrum/table" + @session.click_link "Link" + expect(@session).to have_content("Hello world") + end + + it "handles evaluate_script values properly" do + expect(@session.evaluate_script("null")).to be_nil + expect(@session.evaluate_script("false")).to be false + expect(@session.evaluate_script("true")).to be true + expect(@session.evaluate_script("undefined")).to eq(nil) + + expect(@session.evaluate_script("3;")).to eq(3) + expect(@session.evaluate_script("31337")).to eq(31337) + expect(@session.evaluate_script(%("string"))).to eq("string") + expect(@session.evaluate_script(%({foo: "bar"}))).to eq("foo" => "bar") + + expect(@session.evaluate_script("new Object")).to eq({}) + expect(@session.evaluate_script("new Date(2012, 0).toDateString()")).to eq("Sun Jan 01 2012") + expect(@session.evaluate_script("new Object({a: 1})")).to eq({"a" => 1}) + expect(@session.evaluate_script("new Array")).to eq([]) + expect(@session.evaluate_script("new Function")).to eq({}) + + expect { @session.evaluate_script(%(throw "smth")) }.to raise_error(Ferrum::JavaScriptError) + end + + it "ignores cyclic structure errors in evaluate_script" do + code = <<-JS + (function() { + var a = {}; + var b = {}; + var c = {}; + c.a = a; + a.a = a; + a.b = b; + a.c = c; + return a; + })() + JS + + expect(@session.evaluate_script(code)).to eq("(cyclic structure)") + end + + it "synchronises page loads properly" do + browser "/ferrum/index" + @session.click_link "JS redirect" + sleep 0.1 + expect(@session.html).to include("Hello world") + end + + context "click tests" do + before do + browser "/ferrum/click_test" + end + + after do + @session.driver.resize(1024, 768) + @session.driver.reset! + end + + it "scrolls around so that elements can be clicked" do + @session.driver.resize(200, 200) + log = @session.find(:css, "#log") + + instructions = %w[one four one two three] + instructions.each do |instruction| + @session.find(:css, "##{instruction}").click + expect(log.text).to eq(instruction) + end + end + + it "fixes some weird layout issue that we are not entirely sure about the reason for" do + browser "/ferrum/datepicker" + @session.find(:css, "#datepicker").set("2012-05-11") + @session.click_link "some link" + end + + it "can click an element inside an svg" do + expect { @session.find(:css, "#myrect").click }.not_to raise_error + end + + context "with #two overlapping #one" do + before do + @session.execute_script <<-JS + var two = document.getElementById("two") + two.style.position = "absolute" + two.style.left = "0px" + two.style.top = "0px" + JS + end + + it "detects if an element is obscured when clicking" do + expect do + @session.find(:css, "#one").click + end.to raise_error(Ferrum::MouseEventFailed) { |error| + expect(error.selector).to eq("html body div#two.box") + expect(error.message).to include("[200.0, 200.0]") + } + end + + it "clicks in the center of an element" do + expect do + @session.find(:css, "#one").click + end.to raise_error(Ferrum::MouseEventFailed) { |error| + expect(error.position).to eq([200, 200]) + } + end + + it "clicks in the center of an element within the viewport, if part is outside the viewport" do + @session.driver.resize(200, 200) + + expect do + @session.find(:css, "#one").click + end.to raise_error(Ferrum::MouseEventFailed) { |error| + expect(error.position.first).to eq(100) + } + end + end + + context "with #svg overlapping #one" do + before do + @session.execute_script <<-JS + var two = document.getElementById("svg") + two.style.position = "absolute" + two.style.left = "0px" + two.style.top = "0px" + JS + end + + it "detects if an element is obscured when clicking" do + expect do + @session.find(:css, "#one").click + end.to raise_error(Ferrum::MouseEventFailed) { |error| + expect(error.selector).to eq("html body svg#svg.box") + expect(error.message).to include("[200.0, 200.0]") + } + end + end + + context "with image maps", skip: true do + before { browser("/ferrum/image_map") } + + it "can click" do + @session.find(:css, "map[name=testmap] area[shape=circle]").click + expect(@session).to have_css("#log", text: "circle clicked") + @session.find(:css, "map[name=testmap] area[shape=rect]").click + expect(@session).to have_css("#log", text: "rect clicked") + end + + it "doesn't click if the associated img is hidden" do + expect do + @session.find(:css, "map[name=testmap2] area[shape=circle]").click + end.to raise_error(Ferrum::ElementNotFound) + expect do + @session.find(:css, "map[name=testmap2] area[shape=circle]", visible: false).click + end.to raise_error(Ferrum::MouseEventFailed) + end + end + end + + context "double click tests" do + before do + browser "/ferrum/double_click_test" + end + + it "double clicks properly" do + @session.driver.resize(200, 200) + log = @session.find(:css, "#log") + + instructions = %w[one four one two three] + instructions.each do |instruction| + @session.find(:css, "##{instruction}").base.double_click + expect(log.text).to eq(instruction) + end + end + end + + context "status code support", status_code_support: true do + it "determines status code when an user goes to a page by using a link on it" do + browser "/ferrum/with_different_resources" + + @session.click_link "Go to 500" + + expect(@session.status_code).to eq(500) + end + + it "determines properly status code when an user goes through a few pages" do + browser "/ferrum/with_different_resources" + + @session.click_link "Go to 201" + @session.click_link "Do redirect" + @session.click_link "Go to 402" + + expect(@session.status_code).to eq(402) + end + end + + it "returns BR as new line in #text" do + browser "/ferrum/simple" + expect(@session.find(:css, "#break").text).to eq("Foo\nBar") + end + + it "handles hash changes" do + browser "/#omg" + expect(@session.current_url).to match(%r{/#omg$}) + @session.execute_script <<-JS + window.onhashchange = function() { window.last_hashchange = window.location.hash } + JS + browser "/#foo" + expect(@session.current_url).to match(%r{/#foo$}) + expect(@session.evaluate_script("window.last_hashchange")).to eq("#foo") + end + + context "current_url" do + let(:request_uri) { URI.parse(@session.current_url).request_uri } + + it "supports whitespace characters" do + browser "/ferrum/arbitrary_path/200/foo%20bar%20baz" + expect(@session.current_path).to eq("/ferrum/arbitrary_path/200/foo%20bar%20baz") + end + + it "supports escaped characters" do + browser "/ferrum/arbitrary_path/200/foo?a%5Bb%5D=c" + expect(request_uri).to eq("/ferrum/arbitrary_path/200/foo?a%5Bb%5D=c") + end + + it "supports url in parameter" do + browser "/ferrum/arbitrary_path/200/foo%20asd?a=http://example.com/asd%20asd" + expect(request_uri).to eq("/ferrum/arbitrary_path/200/foo%20asd?a=http://example.com/asd%20asd") + end + + it "supports restricted characters ' []:/+&='" do + browser "/ferrum/arbitrary_path/200/foo?a=%20%5B%5D%3A%2F%2B%26%3D" + expect(request_uri).to eq("/ferrum/arbitrary_path/200/foo?a=%20%5B%5D%3A%2F%2B%26%3D") + end + + it "returns about:blank when on about:blank" do + browser "about:blank" + expect(@session.current_url).to eq("about:blank") + end + end + + context "dragging support", skip: true do + before do + browser "/ferrum/drag" + end + + it "supports drag_to" do + draggable = @session.find(:css, "#drag_to #draggable") + droppable = @session.find(:css, "#drag_to #droppable") + + draggable.drag_to(droppable) + expect(droppable).to have_content("Dropped") + end + + it "supports drag_by on native element" do + draggable = @session.find(:css, "#drag_by .draggable") + + top_before = @session.evaluate_script(%($("#drag_by .draggable").position().top)) + left_before = @session.evaluate_script(%($("#drag_by .draggable").position().left)) + + draggable.native.drag_by(15, 15) + + top_after = @session.evaluate_script(%($("#drag_by .draggable").position().top)) + left_after = @session.evaluate_script(%($("#drag_by .draggable").position().left)) + + expect(top_after).to eq(top_before + 15) + expect(left_after).to eq(left_before + 15) + end + end + + context "window switching support" do + it "waits for the window to load" do + browser.goto + + popup = @session.window_opened_by do + @session.execute_script <<-JS + window.open("/ferrum/slow", "popup") + JS + end + + @session.within_window(popup) do + expect(@session.html).to include("slow page") + end + popup.close + end + + it "can access a second window of the same name" do + browser.goto + + popup = @session.window_opened_by do + @session.execute_script <<-JS + window.open("/ferrum/simple", "popup") + JS + end + @session.within_window(popup) do + expect(@session.html).to include("Test") + end + popup.close + + sleep 0.5 # https://github.com/ChromeDevTools/devtools-protocol/issues/145 + + same = @session.window_opened_by do + @session.execute_script <<-JS + window.open("/ferrum/simple", "popup") + JS + end + @session.within_window(same) do + expect(@session.html).to include("Test") + end + same.close + end + end + + context "frame support" do + it "supports selection by index" do + browser "/ferrum/frames" + + @session.within_frame 0 do + expect(@session.driver.frame_url).to end_with("/ferrum/slow") + end + end + + it "supports selection by element" do + browser "/ferrum/frames" + frame = @session.find(:css, "iframe[name]") + + @session.within_frame(frame) do + expect(@session.driver.frame_url).to end_with("/ferrum/slow") + end + end + + it "supports selection by element without name or id" do + browser "/ferrum/frames" + frame = @session.find(:css, "iframe:not([name]):not([id])") + + @session.within_frame(frame) do + expect(@session.driver.frame_url).to end_with("/ferrum/headers") + end + end + + it "supports selection by element with id but no name" do + browser "/ferrum/frames" + frame = @session.find(:css, "iframe[id]:not([name])") + + @session.within_frame(frame) do + expect(@session.driver.frame_url).to end_with("/ferrum/get_cookie") + end + end + + it "waits for the frame to load" do + browser.goto + + @session.execute_script <<-JS + document.body.innerHTML += "