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 += " + + diff --git a/spec/support/views/attach_file.erb b/spec/support/views/attach_file.erb new file mode 100644 index 00000000..1bcad007 --- /dev/null +++ b/spec/support/views/attach_file.erb @@ -0,0 +1,10 @@ + + + diff --git a/spec/support/views/attributes_properties.erb b/spec/support/views/attributes_properties.erb new file mode 100644 index 00000000..581d5d18 --- /dev/null +++ b/spec/support/views/attributes_properties.erb @@ -0,0 +1,10 @@ + + + + Link me +

foobar

+ Loop + + + + diff --git a/spec/support/views/basic_auth.erb b/spec/support/views/basic_auth.erb new file mode 100644 index 00000000..554e43b6 --- /dev/null +++ b/spec/support/views/basic_auth.erb @@ -0,0 +1,5 @@ +Welcome, authenticated client + +
+ +
diff --git a/spec/support/views/buttons.erb b/spec/support/views/buttons.erb new file mode 100644 index 00000000..3276fb67 --- /dev/null +++ b/spec/support/views/buttons.erb @@ -0,0 +1,5 @@ + +

Buttons

+ + + diff --git a/spec/support/views/click_coordinates.erb b/spec/support/views/click_coordinates.erb new file mode 100644 index 00000000..3b342b3b --- /dev/null +++ b/spec/support/views/click_coordinates.erb @@ -0,0 +1,30 @@ + + + + + + + +
+
+ + diff --git a/spec/support/views/click_test.erb b/spec/support/views/click_test.erb new file mode 100644 index 00000000..c9572c28 --- /dev/null +++ b/spec/support/views/click_test.erb @@ -0,0 +1,52 @@ + + + + + + + +
+
+
+
+
+ + + + + + diff --git a/spec/support/views/console_log.erb b/spec/support/views/console_log.erb new file mode 100644 index 00000000..0eded64f --- /dev/null +++ b/spec/support/views/console_log.erb @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/spec/support/views/date_fields.erb b/spec/support/views/date_fields.erb new file mode 100644 index 00000000..7928f653 --- /dev/null +++ b/spec/support/views/date_fields.erb @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/spec/support/views/datepicker.erb b/spec/support/views/datepicker.erb new file mode 100644 index 00000000..05ad2dce --- /dev/null +++ b/spec/support/views/datepicker.erb @@ -0,0 +1,15 @@ + + + + ferrum and datepicker + + + + + + + some link +
+ + + diff --git a/spec/support/views/double_click_test.erb b/spec/support/views/double_click_test.erb new file mode 100644 index 00000000..055c1d28 --- /dev/null +++ b/spec/support/views/double_click_test.erb @@ -0,0 +1,48 @@ + + + + + + + +
+
+
+
+
+ + diff --git a/spec/support/views/drag.erb b/spec/support/views/drag.erb new file mode 100644 index 00000000..53a578ff --- /dev/null +++ b/spec/support/views/drag.erb @@ -0,0 +1,59 @@ + + + + ferrum drag_to and drag_by + + + + + + + + + + + +
+
+

Drag me

+
+ +
+

Drop here

+
+
+ +
+
+

Drag me by

+
+
+ + + diff --git a/spec/support/views/fieldsets.erb b/spec/support/views/fieldsets.erb new file mode 100644 index 00000000..e4b6cd23 --- /dev/null +++ b/spec/support/views/fieldsets.erb @@ -0,0 +1,30 @@ + +
+
+ Agent + +

+ + +

+ +

+ +

+
+
+ +
+
+ Villain + +

+ + +

+ +

+ +

+
+
diff --git a/spec/support/views/filter_text_test.erb b/spec/support/views/filter_text_test.erb new file mode 100644 index 00000000..46f9c62e --- /dev/null +++ b/spec/support/views/filter_text_test.erb @@ -0,0 +1,9 @@ + + + +

foo

+

bar

+

 baz    

+

    qux      

+ + diff --git a/spec/support/views/fixed_positioning.erb b/spec/support/views/fixed_positioning.erb new file mode 100644 index 00000000..11f0c5f6 --- /dev/null +++ b/spec/support/views/fixed_positioning.erb @@ -0,0 +1,6 @@ + + +
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+
+ diff --git a/spec/support/views/form.erb b/spec/support/views/form.erb new file mode 100644 index 00000000..03369d5b --- /dev/null +++ b/spec/support/views/form.erb @@ -0,0 +1,670 @@ + +

Form

+ +
+ +

+ + +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ + +

+ +

+ +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ +

+ +

+ + +

+ +

+ + +

+ +

+ + + +

+ +

+ + +

+ +

+ + +

+ +

+ +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+
+ +

+ +

+
+ +

+ +

+ + + + + + +

+ +

+ + + + + + +

+ +

+ +

+ +

+ + + + + + +

+ +

+ + + + + + + + +

+ +
+ +
+
+ + + + + + +

+ +

+ + + + +

+ +

+ + + + +

+ +

+ + +

+ +

+ + +

+ + +

+ + +

+ + +

+ + +

+ + +

+ + +

+ + +

+ + +

+ +

+ First address + + + + + + + + +

+ +

+ Second address + + + + + + + + +

+ +
+ +
+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +
+ +
+ +
+ Disabled Child + + +
+ +
+ + Nested Disabled + + + + Another WLegend + + +
+ + Disabled? + + + +
+
+ +

+ +

+ +

+ + + + + + + + + +

+ +

+ + + +

+ +

+ + +

+ +

+ + +

+
+ + + + + +
+ + + +
+ + + +
+ + + +
+ + + + + + + + +
+

+ + +

+ +

+ +

+

+ +
+

+ + +

+ +

+ + +

+

+ +
+

+ +

+ +

+ + +

+

+ +
+

+ + +

+ +

+ + +

+ +

+ +

+

+ +
+

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ +

+

+ +
+

+ +

+ + +

+ +

+

+ +

+
+ +
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

Emergency Number

+

+ + +

+

+ + +

+ +

+ +

+
+ +
+

+ + + + +

+
+ +
+

+ +

+ + +
+ +
+

+ +

+
+ +
+

+ +

+
+ + + + + + + +

+ + +

+ +

+ +

diff --git a/spec/support/views/frame_child.erb b/spec/support/views/frame_child.erb new file mode 100644 index 00000000..0583f1ab --- /dev/null +++ b/spec/support/views/frame_child.erb @@ -0,0 +1,18 @@ + + + + This is the child frame title + + + + Close Window Now + Close Window Soon + + + + diff --git a/spec/support/views/frame_one.erb b/spec/support/views/frame_one.erb new file mode 100644 index 00000000..58e52edf --- /dev/null +++ b/spec/support/views/frame_one.erb @@ -0,0 +1,10 @@ + + + + This is the title of frame one + + +
This is the text of divInFrameOne
+
Some other text
+ + diff --git a/spec/support/views/frame_parent.erb b/spec/support/views/frame_parent.erb new file mode 100644 index 00000000..151665ec --- /dev/null +++ b/spec/support/views/frame_parent.erb @@ -0,0 +1,9 @@ + + + + This is the parent frame title + + + + + diff --git a/spec/support/views/frame_two.erb b/spec/support/views/frame_two.erb new file mode 100644 index 00000000..c1e6d623 --- /dev/null +++ b/spec/support/views/frame_two.erb @@ -0,0 +1,9 @@ + + + + This is the title of frame two + + +
This is the text of divInFrameTwo
+ + diff --git a/spec/support/views/frames.erb b/spec/support/views/frames.erb new file mode 100644 index 00000000..0431a6e2 --- /dev/null +++ b/spec/support/views/frames.erb @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/spec/support/views/header_links.erb b/spec/support/views/header_links.erb new file mode 100644 index 00000000..ef4b4128 --- /dev/null +++ b/spec/support/views/header_links.erb @@ -0,0 +1,8 @@ + +

+ Link +

+ +
+

+
diff --git a/spec/support/views/headers.erb b/spec/support/views/headers.erb new file mode 100644 index 00000000..05e2cc5a --- /dev/null +++ b/spec/support/views/headers.erb @@ -0,0 +1,3 @@ +<% for header in request.env.select {|k,v| k.match("^HTTP.*")} %> + <%=header[0].split("_",2)[1]%>: <%=header[1]%> +<% end %> diff --git a/spec/support/views/headers_with_ajax.erb b/spec/support/views/headers_with_ajax.erb new file mode 100644 index 00000000..4371b1ee --- /dev/null +++ b/spec/support/views/headers_with_ajax.erb @@ -0,0 +1,25 @@ + + + + + + +
+ <% for header in request.env.select {|k,v| k.match("^HTTP.*")} %> + <%=header[0].split("_",2)[1]%>: <%=header[1]%> + <% end %> +
+
+ + diff --git a/spec/support/views/host_links.erb b/spec/support/views/host_links.erb new file mode 100644 index 00000000..3c139d6e --- /dev/null +++ b/spec/support/views/host_links.erb @@ -0,0 +1,13 @@ + +

+ Relative Host + Absolute Host +

+ +
+

+
+ +
+

+
diff --git a/spec/support/views/image_map.erb b/spec/support/views/image_map.erb new file mode 100644 index 00000000..27ee15b9 --- /dev/null +++ b/spec/support/views/image_map.erb @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + +
+
+ + diff --git a/spec/support/views/index.erb b/spec/support/views/index.erb new file mode 100644 index 00000000..b99c6c18 --- /dev/null +++ b/spec/support/views/index.erb @@ -0,0 +1,6 @@ + + + + JS redirect + + diff --git a/spec/support/views/initial_alert.erb b/spec/support/views/initial_alert.erb new file mode 100644 index 00000000..c0294d79 --- /dev/null +++ b/spec/support/views/initial_alert.erb @@ -0,0 +1,10 @@ + + +
+ Initial alert page +
+ + + diff --git a/spec/support/views/input_events.erb b/spec/support/views/input_events.erb new file mode 100644 index 00000000..8b1cfdb2 --- /dev/null +++ b/spec/support/views/input_events.erb @@ -0,0 +1,17 @@ + + + +
+ + + + diff --git a/spec/support/views/js_error.erb b/spec/support/views/js_error.erb new file mode 100644 index 00000000..181bb800 --- /dev/null +++ b/spec/support/views/js_error.erb @@ -0,0 +1,5 @@ + + +

hello

diff --git a/spec/support/views/js_redirect.erb b/spec/support/views/js_redirect.erb new file mode 100644 index 00000000..c0849ae3 --- /dev/null +++ b/spec/support/views/js_redirect.erb @@ -0,0 +1,8 @@ + + + + + + diff --git a/spec/support/views/long_page.erb b/spec/support/views/long_page.erb new file mode 100644 index 00000000..03a91b58 --- /dev/null +++ b/spec/support/views/long_page.erb @@ -0,0 +1,41 @@ +
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin lacus odio, dapibus id bibendum in, rhoncus sed dolor. In quis nulla at diam euismod suscipit vitae vitae sapien. Nam viverra hendrerit augue a accumsan. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Fusce fermentum tortor at neque malesuada sodales. Nunc quis augue a quam venenatis pharetra sit amet et risus. Nulla pharetra enim a leo varius scelerisque aliquam urna vestibulum. Sed felis eros, iaculis convallis fermentum ac, condimentum ac lacus. Sed turpis magna, tristique eu faucibus non, faucibus vitae elit. Morbi venenatis adipiscing aliquam.

+ +

Etiam pharetra tellus eget lorem gravida sollicitudin. Curabitur malesuada pellentesque velit eu ullamcorper. In metus neque, lobortis at viverra nec, porta ac metus. Duis convallis dolor sed neque accumsan rhoncus varius elit aliquet. Sed iaculis bibendum vehicula. Duis vestibulum suscipit consequat. In mattis porttitor enim vitae sollicitudin. Phasellus condimentum dictum turpis, ac viverra neque placerat ut. Suspendisse ac arcu sed enim molestie placerat eget scelerisque quam.

+ +

Suspendisse nec nunc libero, sed gravida eros. Sed congue tellus eu purus ornare a rhoncus nisl adipiscing. Quisque pharetra est sit amet lectus vulputate imperdiet. In nec libero at tellus accumsan tristique eget eget odio. Nulla tempus, tortor at hendrerit fermentum, augue erat adipiscing dui, a lobortis risus massa non metus. Vestibulum non justo nisi, et vulputate elit. Etiam pellentesque sagittis tellus, vel tincidunt lacus consectetur vel.

+ +

Aliquam nibh metus, tincidunt a sodales vitae, accumsan sed ligula. Vivamus et ornare leo. Donec laoreet rhoncus ligula a suscipit. Nulla est sapien, varius varius adipiscing aliquet, tincidunt nec est. Donec dictum adipiscing ipsum, ac semper orci tristique at. Pellentesque suscipit odio ac enim pretium at dictum felis pharetra. Nam eget arcu arcu.

+ +

Nulla tincidunt ante vel dui imperdiet et mattis quam dictum. Pellentesque tempus, sem eu volutpat iaculis, diam urna aliquam felis, sit amet cursus ligula sem eget felis. Nullam aliquam dictum elit eu aliquam. Phasellus malesuada, quam id sodales tincidunt, magna nisl rutrum enim, sit amet varius mi elit eu tellus. Nullam venenatis, ligula eget hendrerit tincidunt, magna ligula aliquam nulla, a ullamcorper eros purus eu odio. Nunc sollicitudin porta lobortis. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aliquam euismod scelerisque porta. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nunc nec dui orci, ut fermentum ligula. Mauris gravida, urna ut scelerisque eleifend, lectus nisi sodales justo, dapibus egestas dui odio ut enim. Fusce a diam in felis tristique blandit. Morbi malesuada vulputate ipsum, sed ornare nisl ultricies fermentum. Mauris in enim non nulla tincidunt sollicitudin eget eget felis.

+ +

Ut malesuada varius facilisis. Fusce pharetra posuere felis a laoreet. Vivamus hendrerit risus quis eros mattis feugiat. Quisque magna sapien, pulvinar nec eleifend ac, luctus id mi. Nunc a libero mauris, vitae venenatis ante. Vestibulum eu venenatis sapien. Praesent lobortis pretium metus, id laoreet sapien tincidunt nec. Nunc condimentum nisl in nunc pharetra suscipit. Donec auctor sollicitudin nunc, nec bibendum velit tristique in. Vivamus erat velit, porttitor ut venenatis eu, hendrerit vitae nulla. Aenean tellus magna, sagittis nec rutrum at, faucibus eu lorem. Mauris malesuada eleifend convallis.

+ +

Vestibulum dignissim sapien eget arcu faucibus quis aliquet nulla rutrum. Ut ante velit, hendrerit a varius a, feugiat sed neque. Proin non neque lacinia nisi commodo varius. Nam laoreet suscipit ultrices. Suspendisse potenti. Sed ultrices posuere metus vitae viverra. Suspendisse suscipit tortor et nunc vehicula vitae venenatis leo dictum. Integer velit diam, pharetra vitae egestas eu, volutpat vestibulum nunc. Praesent odio metus, cursus nec posuere nec, tempor a sem. Integer ut est magna, eget blandit nisl. Cras at ipsum quis ante pharetra laoreet. Phasellus varius, ligula pretium pellentesque tempor, dolor mi porta tellus, ac consequat lacus ipsum id purus. Cras a cursus lorem. Nullam risus odio, auctor in vulputate ut, placerat vitae velit. Donec pulvinar scelerisque varius.

+ +

Etiam in luctus massa. Quisque adipiscing enim eu lacus aliquam vitae interdum magna malesuada. Integer dignissim ante nec elit laoreet fringilla. Morbi eu tortor feugiat dui iaculis luctus sit amet et orci. Suspendisse potenti. Aliquam pellentesque, tellus eu varius suscipit, velit eros feugiat purus, eget tincidunt nunc massa vitae sapien. Cras pellentesque tristique suscipit. Maecenas vel lorem est, non condimentum felis. In mi massa, commodo et mollis ac, aliquam in risus. Morbi malesuada aliquam malesuada. Suspendisse hendrerit mi quis nisi lacinia venenatis. Pellentesque quis dignissim ante. Suspendisse dignissim fringilla tristique. Sed eleifend tempus erat ac lacinia. Pellentesque placerat tellus sagittis velit dapibus adipiscing interdum tellus pellentesque.

+ +

Ut quis scelerisque tortor. Donec ultricies convallis arcu sed fringilla. Quisque sed mauris sem, at convallis dui. Curabitur non erat ipsum. Proin dignissim, nisl non faucibus pulvinar, nulla lacus consectetur turpis, eu vestibulum lectus ipsum rhoncus nulla. Quisque ut diam quis nibh malesuada accumsan eu non elit. Cras in lectus id odio dignissim elementum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce eget nibh arcu, at commodo lorem. Proin ac lobortis turpis. Fusce a risus dui. Vestibulum pellentesque lacinia elit, non dictum lectus luctus sit amet.

+ +

Curabitur dictum ligula et tellus luctus eget pharetra nisi lobortis. Donec eu varius libero. Curabitur lorem magna, semper at porta sed, iaculis nec lacus. In pretium erat eu ipsum dapibus pharetra. In gravida aliquet urna eget bibendum. Sed varius mi eu est sagittis vel tempor elit venenatis. Pellentesque fringilla ultricies enim sed aliquam. Nulla mattis semper erat, non rhoncus libero condimentum vel. Suspendisse eget eros commodo est malesuada accumsan. Aliquam ultricies consectetur justo, at volutpat libero pellentesque eu. Aliquam non justo id sapien mattis viverra. Morbi semper rutrum nunc dignissim blandit. Cras felis lorem, volutpat ac gravida sed, lobortis id mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam tincidunt tortor ac metus elementum dapibus vel nec nisl. Etiam at nibh nec nisi luctus bibendum vitae vel diam.

+ +

Praesent placerat nisi nec nulla ullamcorper facilisis. Nunc quis porta erat. Pellentesque et diam eu tellus aliquet porttitor a ut lorem. Nulla facilisi. Pellentesque elementum imperdiet tempus. Morbi vulputate tempor orci, quis tempor nibh pellentesque vitae. Phasellus varius ultrices diam ut faucibus. In adipiscing condimentum commodo. Sed ultrices nulla quis libero pharetra malesuada. Integer tempus volutpat sapien, elementum luctus enim bibendum non.

+ +

Sed eu quam id ligula sodales venenatis a ac leo. Fusce luctus mattis lorem, in commodo leo dictum non. Curabitur sit amet erat ut libero cursus convallis at eget mi. Nam nec turpis sit amet ligula malesuada aliquet quis id turpis. Curabitur blandit libero sed ipsum convallis semper tincidunt quam ultrices. Nulla vel neque elit, eu ornare urna. Curabitur vulputate suscipit sem quis molestie. Vestibulum nulla ipsum, porttitor varius facilisis in, laoreet in odio. Cras nec enim eget nulla mollis laoreet. Maecenas pulvinar imperdiet ante, at tristique erat luctus sit amet. Nullam convallis ornare ante in feugiat. Cras id ipsum ut dolor malesuada rutrum sit amet ut lacus. Cras nec lectus pretium lectus egestas mattis ut in ipsum.

+ +

Proin vitae massa massa, non molestie nibh. Aliquam erat volutpat. Cras in sapien purus, eget pharetra diam. Donec dignissim dictum consequat. Suspendisse varius, urna ut porta imperdiet, mauris elit egestas nibh, tempor aliquam turpis nulla ac leo. Suspendisse potenti. Vestibulum mi lorem, facilisis ultricies accumsan nec, mattis at velit. Etiam urna erat, commodo at blandit nec, eleifend nec nibh.

+ +

Donec semper felis ac nulla aliquam pulvinar. Vivamus convallis ante eget lectus mollis vitae tempor purus euismod. Praesent suscipit metus et elit egestas egestas. Donec varius fringilla mauris, ut fringilla nulla feugiat consectetur. Aenean cursus pharetra condimentum. In vestibulum, metus vel dictum aliquam, nunc lectus tempor justo, nec fermentum nulla diam eu urna. Duis ac urna neque. Nulla facilisi. Suspendisse potenti. Aenean justo diam, laoreet ut euismod id, ultricies interdum massa. Etiam adipiscing velit sed mauris ornare aliquet blandit tellus iaculis. Aliquam elementum sollicitudin euismod.

+ +

Aenean blandit lectus non augue placerat sed gravida tellus interdum. Duis vel nibh ante, nec rhoncus lorem. Sed a leo orci. Etiam a placerat orci. Aenean sit amet felis et lorem tincidunt consequat. Nulla commodo scelerisque malesuada. In ullamcorper porttitor facilisis.

+ +

Nunc congue libero vel risus dignissim posuere id ut augue. Sed sit amet metus euismod arcu porttitor ultrices. Sed et erat a elit rhoncus tincidunt. Suspendisse potenti. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque scelerisque gravida erat, sed luctus odio ultricies et. Proin imperdiet molestie leo vitae molestie. Phasellus blandit, nisi at ullamcorper vulputate, neque eros aliquam nisi, vel facilisis dui orci non lorem. Fusce malesuada lacus fringilla massa sollicitudin scelerisque. Donec rutrum lacus id mauris scelerisque dignissim. Vivamus elementum nulla sit amet ante porttitor in porttitor ipsum laoreet. Vestibulum commodo dolor id velit dictum a mollis magna hendrerit. Nunc ac neque sapien.

+ +

Nam gravida vulputate purus, nec mattis ligula congue id. Curabitur viverra magna mi. Phasellus id justo ut eros tempus placerat ut eget nisi. Aenean lectus est, aliquet eget egestas non, pellentesque vel risus. Fusce quam velit, pharetra vitae dictum at, ornare quis nisl. Duis tempor adipiscing massa, nec venenatis nulla viverra sed. Donec placerat orci in est ultricies sollicitudin vel in enim. In hac habitasse platea dictumst. Mauris et magna sapien, eget dictum nisl. Nunc id eros dui, non commodo justo. Aliquam venenatis ornare risus, ut venenatis sem imperdiet sed. Donec a odio lacus, quis fringilla neque.

+ +

Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Proin gravida tempor dolor, vestibulum ornare felis consequat quis. Sed suscipit diam convallis lacus egestas at ultricies nisl fermentum. Vestibulum sodales, lectus sed molestie bibendum, tellus elit luctus arcu, sit amet malesuada felis massa ac tellus. Fusce massa arcu, aliquam quis accumsan ut, ullamcorper at odio. Aenean scelerisque, lacus venenatis tincidunt commodo, urna elit mollis velit, a lacinia dui quam et turpis. Etiam ut tortor eget ipsum facilisis fermentum. Curabitur at neque sit amet nisl fringilla pulvinar eget quis elit. Quisque id magna et diam suscipit facilisis. Sed consequat volutpat lacinia.

+ +

Praesent viverra, augue vel vestibulum gravida, diam mauris sodales massa, eget cursus nisi sem in libero. Sed convallis molestie pellentesque. Ut vel ipsum massa, sit amet cursus quam. Nullam dapibus, elit eu lobortis malesuada, tellus ante consectetur diam, eget dignissim mi ante in nisl. Mauris egestas bibendum laoreet. Morbi tincidunt feugiat magna, et rutrum lectus laoreet eget. Ut ac tortor ante. In odio tortor, rhoncus a rhoncus sed, viverra ultrices metus. Quisque mollis massa velit, cursus auctor ligula. Quisque egestas arcu erat.

+ +

Phasellus blandit velit non dolor bibendum eleifend. In lobortis metus vel lorem auctor fermentum nec pulvinar nisl. Vestibulum urna mauris, malesuada quis viverra sit amet, convallis vel arcu. Mauris quis tortor ipsum, ac cursus erat. Cras laoreet accumsan elit, sed convallis nibh scelerisque vel. Praesent nec nunc dolor, et rutrum sem. Integer sagittis imperdiet arcu, et dictum nisl mattis eget. In sapien tellus, eleifend ut accumsan id, aliquam quis risus. Cras viverra neque et augue fringilla eu malesuada felis tincidunt. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer a magna non tellus fringilla gravida sed quis justo. Cras pulvinar ultricies tincidunt. Nullam hendrerit risus id massa feugiat iaculis. Duis rhoncus ipsum dolor, ut semper dolor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.

+
diff --git a/spec/support/views/nested_frame_test.erb b/spec/support/views/nested_frame_test.erb new file mode 100644 index 00000000..f8e72fa7 --- /dev/null +++ b/spec/support/views/nested_frame_test.erb @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/spec/support/views/obscured.erb b/spec/support/views/obscured.erb new file mode 100644 index 00000000..5f8e020a --- /dev/null +++ b/spec/support/views/obscured.erb @@ -0,0 +1,47 @@ + + + + Obscured + + + +
+ +
+
+ + +
+
+
+ + + diff --git a/spec/support/views/offset.erb b/spec/support/views/offset.erb new file mode 100644 index 00000000..7db2dbfa --- /dev/null +++ b/spec/support/views/offset.erb @@ -0,0 +1,32 @@ + + + + Offset + + + + + +
+
+
+ + \ No newline at end of file diff --git a/spec/support/views/orig_scroll.erb b/spec/support/views/orig_scroll.erb new file mode 100644 index 00000000..d95c9318 --- /dev/null +++ b/spec/support/views/orig_scroll.erb @@ -0,0 +1,20 @@ + + + + + +
scroll
+
+
+
inner
+
+
+ + + \ No newline at end of file diff --git a/spec/support/views/orig_with_js.erb b/spec/support/views/orig_with_js.erb new file mode 100644 index 00000000..a9da121d --- /dev/null +++ b/spec/support/views/orig_with_js.erb @@ -0,0 +1,158 @@ + + + + with_js + + + + + + +

FooBar

+ +

This is text

+ This link is non-HTML5 draggable +
+

This is a draggable element.

+
+
+

It should be dropped here.

+
+
+

It should be dropped here.

+
+
+

It should be dropped here.

+
+
+

This is an HTML5 draggable element.

+
+

This is an HTML5 draggable link

+
+

It should be dropped here.

+
+ +

Click me

+

Slowly

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+

Editable content
+
+
+ Some content +
Content
+
+

+ +

+ +

+ +

+ +

+ +

+ Reload! + this won't change +

waiting to be reloaded
+

+ +

+ Fetch new list! +

+

+ +

+ Change title +

+ +

+ Change size +

+ +

Click me

+ +

+ Open alert + Alert page change +

+ +

+ Open delayed alert + Open slow alert +

+ +

+ Open confirm +

+ +

+ Open check twice +

+ +

+ Open prompt +

+ +

+ Open defaulted prompt +

+ +

+ +

+

+ +

+

+ Change page + Non-escaped query options + Escaped query options +

+ +

+ +

+

+ +

+ + +

+ +

+ +

+ +
+

This is a draggable element.

+
+
+

This is an HTML5 draggable element.

+
+ + + + diff --git a/spec/support/views/path.erb b/spec/support/views/path.erb new file mode 100644 index 00000000..8c43921e --- /dev/null +++ b/spec/support/views/path.erb @@ -0,0 +1,13 @@ + + + +
+ First Link +
+ Text node +
+ Second Link + Third Link +
+ + diff --git a/spec/support/views/popup_headers.erb b/spec/support/views/popup_headers.erb new file mode 100644 index 00000000..c40b155f --- /dev/null +++ b/spec/support/views/popup_headers.erb @@ -0,0 +1 @@ +pop up diff --git a/spec/support/views/popup_one.erb b/spec/support/views/popup_one.erb new file mode 100644 index 00000000..3f6e62db --- /dev/null +++ b/spec/support/views/popup_one.erb @@ -0,0 +1,9 @@ + + + + Title of the first popup + + +
This is the text of divInPopupOne
+ + diff --git a/spec/support/views/popup_two.erb b/spec/support/views/popup_two.erb new file mode 100644 index 00000000..ef6f8bf0 --- /dev/null +++ b/spec/support/views/popup_two.erb @@ -0,0 +1,9 @@ + + + + Title of popup two + + +
This is the text of divInPopupTwo
+ + diff --git a/spec/support/views/postback.erb b/spec/support/views/postback.erb new file mode 100644 index 00000000..dcd7e88a --- /dev/null +++ b/spec/support/views/postback.erb @@ -0,0 +1,14 @@ + +

Postback

+ +
+

+ +

+
+ +
+

+ +

+
diff --git a/spec/support/views/react.erb b/spec/support/views/react.erb new file mode 100644 index 00000000..78e00d18 --- /dev/null +++ b/spec/support/views/react.erb @@ -0,0 +1,45 @@ + + + + + + + +
+ + + \ No newline at end of file diff --git a/spec/support/views/requiring_custom_extension.erb b/spec/support/views/requiring_custom_extension.erb new file mode 100644 index 00000000..5c0828bb --- /dev/null +++ b/spec/support/views/requiring_custom_extension.erb @@ -0,0 +1,14 @@ + + + + + +

Location:

+ + + + diff --git a/spec/support/views/scroll.erb b/spec/support/views/scroll.erb new file mode 100644 index 00000000..be445af1 --- /dev/null +++ b/spec/support/views/scroll.erb @@ -0,0 +1,22 @@ + + + + scroll + + + +
+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin lacus odio, dapibus id bibendum in, rhoncus sed dolor. In quis nulla at diam euismod suscipit vitae vitae sapien. Nam viverra hendrerit augue a accumsan. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Fusce fermentum tortor at neque malesuada sodales. Nunc quis augue a quam venenatis pharetra sit amet et risus. Nulla pharetra enim a leo varius scelerisque aliquam urna vestibulum. Sed felis eros, iaculis convallis fermentum ac, condimentum ac lacus. Sed turpis magna, tristique eu faucibus non, faucibus vitae elit. Morbi venenatis adipiscing aliquam.

+
+ +

+ Link outside viewport +

+
+
+ +
Below the fold
+
+ + diff --git a/spec/support/views/send_keys.erb b/spec/support/views/send_keys.erb new file mode 100644 index 00000000..bdad2cc1 --- /dev/null +++ b/spec/support/views/send_keys.erb @@ -0,0 +1,35 @@ + + + + + + + + + + +
+
Content
+ +
+ +
+
+ + + diff --git a/spec/support/views/set.erb b/spec/support/views/set.erb new file mode 100644 index 00000000..1e9289ea --- /dev/null +++ b/spec/support/views/set.erb @@ -0,0 +1,10 @@ + + + + + + +
+
Content
+ + diff --git a/spec/support/views/simple.erb b/spec/support/views/simple.erb new file mode 100644 index 00000000..fd353874 --- /dev/null +++ b/spec/support/views/simple.erb @@ -0,0 +1,15 @@ + + + + Test + + + + + Link + +

Foo
Bar

+ + diff --git a/spec/support/views/svg_test.erb b/spec/support/views/svg_test.erb new file mode 100644 index 00000000..6e651f2d --- /dev/null +++ b/spec/support/views/svg_test.erb @@ -0,0 +1,12 @@ + + + + + + + svg foo + + + + + diff --git a/spec/support/views/table.erb b/spec/support/views/table.erb new file mode 100644 index 00000000..bbe51bbf --- /dev/null +++ b/spec/support/views/table.erb @@ -0,0 +1,7 @@ + + + + +
+ Link +
diff --git a/spec/support/views/tables.erb b/spec/support/views/tables.erb new file mode 100644 index 00000000..e26e20be --- /dev/null +++ b/spec/support/views/tables.erb @@ -0,0 +1,130 @@ + +
+ + + + + + + + + + + +
Agent
+ + + +
+ +
+
+ +
+ + + + + + + + + + + +
Girl
+ + + +
+ +
+
+ +
+ + + + + + + + + + + +
Villain
+ + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Horizontal Headers
First NameLast NameCity
ThomasWalpoleOceanside
DaniloWilkinsonJohnsonville
VernKonopelskiEverette
RatkeLawrenceEast Sorayashire
PalmerSawaynWest Trinidad
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Vertical Headers
First NameThomasDaniloVernRatkePalmer
Last NameWalpoleWilkinsonKonopelskiLawrenceSawayn
CityOceansideJohnsonvilleEveretteEast SorayashireWest Trinidad
+ diff --git a/spec/support/views/unwanted.erb b/spec/support/views/unwanted.erb new file mode 100644 index 00000000..c56e466e --- /dev/null +++ b/spec/support/views/unwanted.erb @@ -0,0 +1,6 @@ + + + + We shouldn't see this. + + diff --git a/spec/support/views/url_blacklist.erb b/spec/support/views/url_blacklist.erb new file mode 100644 index 00000000..d903db64 --- /dev/null +++ b/spec/support/views/url_blacklist.erb @@ -0,0 +1,9 @@ + + + + We are loading some unwanted action here. + + + + + +

Here

+ + + diff --git a/spec/support/views/with_animation.erb b/spec/support/views/with_animation.erb new file mode 100644 index 00000000..c64ae040 --- /dev/null +++ b/spec/support/views/with_animation.erb @@ -0,0 +1,74 @@ + + + + + with_animation + + + + + + + + + transition me away + + animate me away + + + + diff --git a/spec/support/views/with_base_tag.erb b/spec/support/views/with_base_tag.erb new file mode 100644 index 00000000..77cfdcd8 --- /dev/null +++ b/spec/support/views/with_base_tag.erb @@ -0,0 +1,11 @@ + + + + + + with_external_source + + +

FooBar

+ +This page is used for testing number options of has_text? + +

count1

+
+

2 count

+

Count

+
diff --git a/spec/support/views/with_different_resources.erb b/spec/support/views/with_different_resources.erb new file mode 100644 index 00000000..fd942b6e --- /dev/null +++ b/spec/support/views/with_different_resources.erb @@ -0,0 +1,15 @@ + + + + ferrum with_different_resources + + + + + + Do redirect + Go to 201 + Go to 402 + Go to 500 + + diff --git a/spec/support/views/with_dragula.erb b/spec/support/views/with_dragula.erb new file mode 100644 index 00000000..d788e4b9 --- /dev/null +++ b/spec/support/views/with_dragula.erb @@ -0,0 +1,22 @@ + + + + with_dragula + + + + +
+
Item 1
+
Item 2
+
Item 3
+
Item 4
+
Item 5
+
+ + + + + diff --git a/spec/support/views/with_fixed_header_footer.erb b/spec/support/views/with_fixed_header_footer.erb new file mode 100644 index 00000000..826748c8 --- /dev/null +++ b/spec/support/views/with_fixed_header_footer.erb @@ -0,0 +1,17 @@ + + + + + +
My headers
+
+
A tall block
+ Go to root +
+ + \ No newline at end of file diff --git a/spec/support/views/with_hover.erb b/spec/support/views/with_hover.erb new file mode 100644 index 00000000..ddb11e62 --- /dev/null +++ b/spec/support/views/with_hover.erb @@ -0,0 +1,24 @@ + + + + + with_hover + + + + + Other hover page +
+ Some text here so the wrapper has size +
Here I am
+
+
+
+ Some text here so the wrapper has size +
Here I am
+
+ + diff --git a/spec/support/views/with_hover1.erb b/spec/support/views/with_hover1.erb new file mode 100644 index 00000000..19df3489 --- /dev/null +++ b/spec/support/views/with_hover1.erb @@ -0,0 +1,10 @@ + + + + + + + + Go back + + \ No newline at end of file diff --git a/spec/support/views/with_html.erb b/spec/support/views/with_html.erb new file mode 100644 index 00000000..62a566a2 --- /dev/null +++ b/spec/support/views/with_html.erb @@ -0,0 +1,199 @@ + + + +
<%= referrer %>
+

This is a test

+ +

+

+

Header Class Test One

+

Header Class Test Two

+

Header Class Test Three

+

Header Class Test Four

+

Header Class RandomTest Five

+ +42 +Other span + +

+ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod + tempor incididunt ut labore + et dolore magna aliqua. Ut enim ad minim veniam, + quis nostrud exercitation ullamco laboris nisi + ut aliquip ex ea commodo consequat. + awesome image +

+ +

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat Redirect pariatur. Excepteur sint occaecat cupidatat non proident, + sunt in culpa qui officia + text with   + whitespace + id est laborum. +

+ +

+ + + + + + BackToMyself + A link came first + A link + A link with data-method + A link with capitalized data-method + No Href + Blank Href + Blank Anchor + Blank JS Anchor + Normal Anchor + Anchor on different page + Anchor on same page + Relative + Protocol + + very fine image + fine image + Disabled link + + Naked Query String + <% if params[:query_string] %> + Query String sent + <% end %> +

+ + + + + + +
+ Some of this text is hidden! +
+ +
+ Some of this text is not hidden and some is hidden +
+ +
+ +
+ +
+ +
+ +
+ visible link +
+ +
+ Number 42 +
+ + + +
+
singular
+
multiple one
+
multiple two
+
almost singular but not quite
+
almost singular
+
+ + + + +
+ No href + Blank href +
+ +
+ text here +
+ +
+ Ancestor +
+ Ancestor +
+ Ancestor +
Child
+
+
+ ASibling +
+
+ +
+ +
+
+
Pre Sibling
+
Mid Sibling
+
Post Sibling
+
+
+
Pre Sibling
+
Post Sibling
+
+
+ +
needs escaping
+ +
+ Some text
More text
+
And more text
+ Even more    text + + on multiple lines +
+ + + +
+                    +
+ +
+ +
Something
+
+ +
+ + + +Download Me +Download Other diff --git a/spec/support/views/with_html5_svg.erb b/spec/support/views/with_html5_svg.erb new file mode 100644 index 00000000..e931f861 --- /dev/null +++ b/spec/support/views/with_html5_svg.erb @@ -0,0 +1,20 @@ + + + + Namespace + + +
+ + + + + + + + + +
+ + diff --git a/spec/support/views/with_html_entities.erb b/spec/support/views/with_html_entities.erb new file mode 100644 index 00000000..52f35144 --- /dev/null +++ b/spec/support/views/with_html_entities.erb @@ -0,0 +1,2 @@ + +Encoding with — html entities » diff --git a/spec/support/views/with_js.erb b/spec/support/views/with_js.erb new file mode 100644 index 00000000..8a57748f --- /dev/null +++ b/spec/support/views/with_js.erb @@ -0,0 +1,75 @@ + + + + ferrum with_js + + + + + + + + +

Remove me

+

Remove

+ +

+

+

+

+ + +

+

+

+

+

+

+

+

+

+

+

+

+ + Hidden link + + + + + + +

+ Open for match +

+ +

+ Open check twice +

+ + diff --git a/spec/support/views/with_jstree.erb b/spec/support/views/with_jstree.erb new file mode 100644 index 00000000..31a57dd8 --- /dev/null +++ b/spec/support/views/with_jstree.erb @@ -0,0 +1,26 @@ + + + + + with_jstree + + +
+
    +
  • Child node A
  • +
  • Child node B
  • +
  • Child node C
  • +
+
+ + + + + diff --git a/spec/support/views/with_namespace.erb b/spec/support/views/with_namespace.erb new file mode 100644 index 00000000..f4d4c421 --- /dev/null +++ b/spec/support/views/with_namespace.erb @@ -0,0 +1,20 @@ + + + Namespace + + +
+ + + + + + + + + +
+ + diff --git a/spec/support/views/with_scope.erb b/spec/support/views/with_scope.erb new file mode 100644 index 00000000..638e62dc --- /dev/null +++ b/spec/support/views/with_scope.erb @@ -0,0 +1,42 @@ + +

This page is used for testing various scopes

+ +

+ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim venia. + Go + +

+ +
+
    +
  • With Simple HTML: Go +
    +

    + + +

    +

    +
    +
  • +
  • Bar: Go +
    +

    + + +

    +

    + + +

    +

    +
    +
  • +
+
+ +
+
    +
  • With Simple HTML: Go +
+
diff --git a/spec/support/views/with_scope_other.erb b/spec/support/views/with_scope_other.erb new file mode 100644 index 00000000..9abe4d3a --- /dev/null +++ b/spec/support/views/with_scope_other.erb @@ -0,0 +1,6 @@ + +

This page is used for testing various scopes

+ +

+ Different text same wrapper id +

diff --git a/spec/support/views/with_simple_html.erb b/spec/support/views/with_simple_html.erb new file mode 100644 index 00000000..538d95bf --- /dev/null +++ b/spec/support/views/with_simple_html.erb @@ -0,0 +1,2 @@ + +Bar diff --git a/spec/support/views/with_slow_unload.erb b/spec/support/views/with_slow_unload.erb new file mode 100644 index 00000000..49d2b07e --- /dev/null +++ b/spec/support/views/with_slow_unload.erb @@ -0,0 +1,17 @@ + + + + + +
This delays unload by 2 seconds
+ + \ No newline at end of file diff --git a/spec/support/views/with_sortable_js.erb b/spec/support/views/with_sortable_js.erb new file mode 100644 index 00000000..97192ff8 --- /dev/null +++ b/spec/support/views/with_sortable_js.erb @@ -0,0 +1,21 @@ + + + + with_sortable_js + + + + +
+
Item 1
+
Item 2
+
Item 3
+
Item 4
+
Item 5
+
+ + + + diff --git a/spec/support/views/with_title.erb b/spec/support/views/with_title.erb new file mode 100644 index 00000000..283df4e4 --- /dev/null +++ b/spec/support/views/with_title.erb @@ -0,0 +1,5 @@ + +Test Title + + abcdefg + diff --git a/spec/support/views/with_unload_alert.erb b/spec/support/views/with_unload_alert.erb new file mode 100644 index 00000000..4bcb4cd0 --- /dev/null +++ b/spec/support/views/with_unload_alert.erb @@ -0,0 +1,14 @@ + + + + + +
This triggers an alert on unload
+ + Go away + + diff --git a/spec/support/views/with_windows.erb b/spec/support/views/with_windows.erb new file mode 100644 index 00000000..e098d99c --- /dev/null +++ b/spec/support/views/with_windows.erb @@ -0,0 +1,54 @@ + + + + With Windows + + + + + + + + + + + + + + + + +
+ My scoped content +
+ + diff --git a/spec/support/views/within_frames.erb b/spec/support/views/within_frames.erb new file mode 100644 index 00000000..fc211cd2 --- /dev/null +++ b/spec/support/views/within_frames.erb @@ -0,0 +1,15 @@ + + + + With Frames + + +
+ This is the text for divInMainWindow + +
+ + + + + diff --git a/spec/support/views/zoom_test.erb b/spec/support/views/zoom_test.erb new file mode 100644 index 00000000..236f8bcf --- /dev/null +++ b/spec/support/views/zoom_test.erb @@ -0,0 +1,6 @@ + + + +
+ + diff --git a/spec/tmp/.keep b/spec/tmp/.keep new file mode 100644 index 00000000..e69de29b diff --git a/spec/unit/browser_spec.rb b/spec/unit/browser_spec.rb new file mode 100644 index 00000000..a08622e6 --- /dev/null +++ b/spec/unit/browser_spec.rb @@ -0,0 +1,26 @@ +# # frozen_string_literal: true +# +# require "spec_helper" +# require "stringio" +# +# module Ferrum +# describe Browser do +# it "logs requests and responses" do +# logger = StringIO.new +# browser = Browser.new(logger: logger) +# +# browser.body +# +# expect(logger.string).to include("return document.documentElement.outerHTML") +# expect(logger.string).to include("") +# end +# +# it "shows command line options passed" do +# browser = Browser.new(browser_options: { "blink-settings" => "imagesEnabled=false" }) +# +# arguments = browser.command("Browser.getBrowserCommandLine")["arguments"] +# +# expect(arguments).to include("--blink-settings=imagesEnabled=false") +# end +# end +# end diff --git a/spec/unit/driver_spec.rb b/spec/unit/driver_spec.rb new file mode 100644 index 00000000..dd24848b --- /dev/null +++ b/spec/unit/driver_spec.rb @@ -0,0 +1,31 @@ +# # frozen_string_literal: true +# +# require "spec_helper" +# +# module Ferrum +# describe Driver do +# context "with no options" do +# subject { Driver.new(nil) } +# +# it "instantiates sucessfully" do +# expect(subject.options).to eq(extensions: []) +# end +# end +# +# context "with a :timeout option" do +# subject { Driver.new(nil, timeout: 3) } +# +# it "starts the server with the provided timeout" do +# expect(subject.browser.timeout).to eq(3) +# end +# end +# +# context "with a :window_size option" do +# subject { Driver.new(nil, window_size: [800, 600]) } +# +# it "creates a client with the desired width and height settings" do +# expect(subject.browser.process.options["window-size"]).to eq("800,600") +# end +# end +# end +# end diff --git a/spec/unit/process_spec.rb b/spec/unit/process_spec.rb new file mode 100644 index 00000000..91650dd7 --- /dev/null +++ b/spec/unit/process_spec.rb @@ -0,0 +1,28 @@ +# # frozen_string_literal: true +# +# require "spec_helper" +# +# module Ferrum +# class Browser +# describe Process do +# subject { Browser.new(port: 6000, host: "127.0.0.1") } +# +# unless Ferrum.windows? +# it "forcibly kills the child if it does not respond to SIGTERM" do +# allow(::Process).to receive_messages(spawn: 5678) +# allow(::Process).to receive(:wait).and_return(nil) +# allow(Client).to receive(:new).and_return(double.as_null_object) +# +# allow_any_instance_of(Process).to receive(:parse_ws_url) +# +# subject.send(:start) +# +# expect(::Process).to receive(:kill).with("USR1", 5678).ordered +# expect(::Process).to receive(:kill).with("KILL", 5678).ordered +# +# subject.quit +# end +# end +# end +# end +# end