diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36298d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.byebug_history diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..bbcf2d1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "puma" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..b42ac8a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,91 @@ +PATH + remote: . + specs: + cuprite (0.1.0) + capybara (>= 2.1, < 4) + cliver (~> 0.3) + websocket-driver (~> 0.7) + +GEM + remote: https://rubygems.org/ + specs: + Ascii85 (1.0.3) + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) + afm (0.2.2) + byebug (10.0.2) + capybara (3.3.1) + addressable + mini_mime (>= 0.1.3) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + xpath (~> 3.1) + cliver (0.3.2) + diff-lcs (1.3) + hashery (2.1.2) + image_size (2.0.0) + launchy (2.4.3) + addressable (~> 2.3) + mini_mime (1.0.0) + mini_portile2 (2.3.0) + mustermann (1.0.2) + nokogiri (1.8.4) + mini_portile2 (~> 2.3.0) + pdf-reader (2.1.0) + Ascii85 (~> 1.0.0) + afm (~> 0.2.1) + hashery (~> 2.0) + ruby-rc4 + ttfunk + public_suffix (3.0.2) + puma (3.11.4) + rack (2.0.5) + rack-protection (2.0.3) + rack + rack-test (1.0.0) + rack (>= 1.0, < 3) + rake (12.3.1) + rspec (3.7.0) + rspec-core (~> 3.7.0) + rspec-expectations (~> 3.7.0) + rspec-mocks (~> 3.7.0) + rspec-core (3.7.1) + rspec-support (~> 3.7.0) + rspec-expectations (3.7.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.7.0) + rspec-mocks (3.7.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.7.0) + rspec-support (3.7.1) + ruby-rc4 (0.1.5) + sinatra (2.0.3) + mustermann (~> 1.0) + rack (~> 2.0) + rack-protection (= 2.0.3) + tilt (~> 2.0) + tilt (2.0.8) + ttfunk (1.5.1) + websocket-driver (0.7.0) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.3) + xpath (3.1.0) + nokogiri (~> 1.8) + +PLATFORMS + ruby + +DEPENDENCIES + byebug (~> 10.0) + cuprite! + image_size (~> 2.0) + launchy (~> 2.4) + pdf-reader (~> 2.1) + puma + rake (~> 12.3) + rspec (~> 3.7) + sinatra (~> 2.0) + +BUNDLED WITH + 1.16.2 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..a26d372 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "rspec/core/rake_task" + +require "capybara/cuprite/version" + +RSpec::Core::RakeTask.new("test") +task default: :test diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..827a9bc --- /dev/null +++ b/bin/console @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby + +require "irb" +require "irb/completion" + +require "cuprite" + +IRB.start diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..f194e54 --- /dev/null +++ b/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "bundler/setup" +require File.expand_path("spec/support/test_app") +run TestApp diff --git a/cuprite.gemspec b/cuprite.gemspec new file mode 100644 index 0000000..124f0df --- /dev/null +++ b/cuprite.gemspec @@ -0,0 +1,31 @@ +lib = File.expand_path("lib", __dir__) +$:.unshift lib unless $:.include?(lib) + +require "capybara/cuprite/version" + +Gem::Specification.new do |s| + s.name = "cuprite" + s.version = Capybara::Cuprite::VERSION + s.platform = Gem::Platform::RUBY + s.authors = ["Dmitry Vorotilin"] + s.email = ["d.vorotilin@gmail.com"] + s.homepage = "https://github.com/machinio/cuprite" + s.summary = "Headless Chrome driver for Capybara" + s.description = "Culprite is a driver for Capybara that allows you to" \ + "run your tests on a headless Chrome browser" + s.license = "MIT" + s.require_paths = ["lib/capybara"] + s.files = Dir["{lib}/**/*"] + + s.add_runtime_dependency "capybara", ">= 2.1", "< 4" + s.add_runtime_dependency "websocket-driver", "~> 0.7" + s.add_runtime_dependency "cliver", "~> 0.3" + + s.add_development_dependency "image_size", "~> 2.0" + s.add_development_dependency "pdf-reader", "~> 2.1" + s.add_development_dependency "rake", "~> 12.3" + s.add_development_dependency "rspec", "~> 3.7" + s.add_development_dependency "sinatra", "~> 2.0" + s.add_development_dependency "launchy", "~> 2.4" + s.add_development_dependency "byebug", "~> 10.0" +end diff --git a/lib/capybara/cuprite.rb b/lib/capybara/cuprite.rb new file mode 100644 index 0000000..0c3638a --- /dev/null +++ b/lib/capybara/cuprite.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "capybara" + +module Capybara::Cuprite + require "cuprite/driver" + require "cuprite/browser" + require "cuprite/node" + require "cuprite/errors" + require "cuprite/cookie" + + class << self + def windows? + RbConfig::CONFIG["host_os"] =~ /mingw|mswin|cygwin/ + end + + def mri? + defined?(RUBY_ENGINE) && RUBY_ENGINE == "ruby" + end + end +end + +Capybara.register_driver(:cuprite) do |app| + Capybara::Cuprite::Driver.new(app) +end diff --git a/lib/capybara/cuprite/browser.rb b/lib/capybara/cuprite/browser.rb new file mode 100644 index 0000000..ae78f7c --- /dev/null +++ b/lib/capybara/cuprite/browser.rb @@ -0,0 +1,401 @@ +require "forwardable" +require "cuprite/browser/process" +require "cuprite/browser/client" + +module Capybara::Cuprite + class Browser + extend Forwardable + + def self.start(*args) + new(*args) + end + + delegate [:command, :wait] => :@client + + def initialize(options = nil) + options ||= {} + @process = Process.start(options.fetch(:browser, {})) + @client = Client.new(@process.host, @process.port) + end + + def visit(url) + command("Page.enable") + command("DOM.enable") + command("CSS.enable") + # command("Page.setLifecycleEventsEnabled", enabled: true) + # command("Runtime.enable") + + command("Page.navigate", url: url) do |response| + wait(event: "Page.frameStoppedLoading", params: { frameId: response["frameId"] }) + end + end + + def current_url + command "current_url" + end + + def frame_url + command "frame_url" + end + + def status_code + command "status_code" + end + + def body + response = command "DOM.getDocument", depth: 0 + response = command "DOM.getOuterHTML", nodeId: response["root"]["nodeId"] + response["outerHTML"] + end + + def source + command "source" + end + + def title + command "title" + end + + def frame_title + command "frame_title" + end + + def parents(page_id, id) + command "parents", page_id, id + end + + def find(_, selector) + response = command("DOM.getDocument", depth: 0) + response = command("DOM.querySelectorAll", nodeId: response["root"]["nodeId"], selector: selector) + result = response["nodeIds"].map do |id| + node = command("DOM.describeNode", nodeId: id)["node"] + node["nodeId"] = id + node["selector"] = selector + [nil, node] # FIXME: page_id + end + + Array(result) + end + + def find_within(page_id, id, method, selector) + command "find_within", page_id, id, method, selector + end + + def all_text(page_id, id) + command "all_text", page_id, id + end + + def visible_text(page_id, node) + command "Runtime.evaluate", expression: <<~JS + $("#{node["selector"]}") + JS + + # command "visible_text", page_id, id + end + + def delete_text(page_id, id) + command "delete_text", page_id, id + end + + def property(page_id, id, name) + command "property", page_id, id, name.to_s + end + + def attributes(page_id, id) + command "attributes", page_id, id + end + + def attribute(page_id, id, name) + command "attribute", page_id, id, name.to_s + end + + def value(page_id, id) + command "value", page_id, id + end + + def set(page_id, id, value) + command "set", page_id, id, value + end + + def select_file(page_id, id, value) + command "select_file", page_id, id, value + end + + def tag_name(page_id, id) + command("tag_name", page_id, id).downcase + end + + def visible?(page_id, node) + response = command "CSS.getComputedStyleForNode", nodeId: node["nodeId"] + style = response["computedStyle"] + display = style.find { |s| s["name"] == "display" }["value"] + visibility = style.find { |s| s["name"] == "visibility" }["value"] + opacity = style.find { |s| s["name"] == "opacity" }["value"] + display == "none" || visibility == "hidden" || opacity == 0 ? false : true + end + + def disabled?(page_id, id) + command "disabled", page_id, id + end + + def click_coordinates(x, y) + command "click_coordinates", x, y + end + + def evaluate(script, *args) + command "evaluate", script, *args + end + + def evaluate_async(script, wait_time, *args) + command "evaluate_async", script, wait_time, *args + end + + def execute(script, *args) + command "execute", script, *args + end + + def within_frame(handle) + if handle.is_a?(Capybara::Node::Base) + command "push_frame", [handle.native.page_id, handle.native.id] + else + command "push_frame", handle + end + + yield + ensure + command "pop_frame" + end + + def switch_to_frame(handle) + case handle + when Capybara::Node::Base + command "push_frame", [handle.native.page_id, handle.native.id] + when :parent + command "pop_frame" + when :top + command "pop_frame", true + end + end + + def window_handle + command "window_handle" + end + + def window_handles + command "window_handles" + end + + def switch_to_window(handle) + command "switch_to_window", handle + end + + def open_new_window + command "open_new_window" + end + + def close_window(handle) + command "close_window", handle + end + + def find_window_handle(locator) + return locator if window_handles.include? locator + + handle = command "window_handle", locator + raise NoSuchWindowError unless handle + handle + end + + def within_window(locator) + original = window_handle + handle = find_window_handle(locator) + switch_to_window(handle) + yield + ensure + switch_to_window(original) + end + + def click(page_id, id, keys = [], offset = {}) + command "click", page_id, id, keys, offset + end + + def right_click(page_id, id, keys = [], offset = {}) + command "right_click", page_id, id, keys, offset + end + + def double_click(page_id, id, keys = [], offset = {}) + command "double_click", page_id, id, keys, offset + end + + def hover(page_id, id) + command "hover", page_id, id + end + + def drag(page_id, id, other_id) + command "drag", page_id, id, other_id + end + + def drag_by(page_id, id, x, y) + command "drag_by", page_id, id, x, y + end + + def select(page_id, id, value) + command "select", page_id, id, value + end + + def trigger(page_id, id, event) + command "trigger", page_id, id, event.to_s + end + + def reset + # command "reset" + restart + end + + def scroll_to(left, top) + command "scroll_to", left, top + end + + def render(path, options = {}) + check_render_options!(options) + options[:full] = !!options[:full] + command "render", path.to_s, options + end + + def render_base64(format, options = {}) + check_render_options!(options) + options[:full] = !!options[:full] + command "render_base64", format.to_s, options + end + + def set_zoom_factor(zoom_factor) + command "set_zoom_factor", zoom_factor + end + + def set_paper_size(size) + command "set_paper_size", size + end + + def resize(width, height) + command "resize", width, height + end + + def send_keys(page_id, id, keys) + command "send_keys", page_id, id, normalize_keys(keys) + end + + def path(page_id, id) + command "path", page_id, id + end + + def network_traffic(type = nil) + end + + def clear_network_traffic + command("clear_network_traffic") + end + + def set_proxy(ip, port, type, user, password) + args = [ip, port, type] + args << user if user + args << password if password + command("set_proxy", *args) + end + + def equals(page_id, id, other_id) + command("equals", page_id, id, other_id) + end + + def get_headers + command "get_headers" + end + + def set_headers(headers) + command "set_headers", headers + end + + def add_headers(headers) + command "add_headers", headers + end + + def add_header(header, options = {}) + command "add_header", header, options + end + + def response_headers + command "response_headers" + end + + def cookies + Hash[command("cookies").map { |cookie| [cookie["name"], Cookie.new(cookie)] }] + end + + def set_cookie(cookie) + cookie[:expires] = cookie[:expires].to_i * 1000 if cookie[:expires] + command "set_cookie", cookie + end + + def remove_cookie(name) + command "remove_cookie", name + end + + def clear_cookies + command "clear_cookies" + end + + def cookies_enabled=(flag) + command "cookies_enabled", !!flag + end + + def set_http_auth(user, password) + command "set_http_auth", user, password + end + + def page_settings=(settings) + command "set_page_settings", settings + end + + def url_whitelist=(whitelist) + command "set_url_whitelist", *whitelist + end + + def url_blacklist=(blacklist) + command "set_url_blacklist", *blacklist + end + + def clear_memory_cache + command "clear_memory_cache" + end + + def go_back + command "go_back" + end + + def go_forward + command "go_forward" + end + + def refresh + command "refresh" + end + + def accept_confirm + command "set_confirm_process", true + end + + def dismiss_confirm + command "set_confirm_process", false + end + + def accept_prompt(response) + command "set_prompt_response", response || false + end + + def dismiss_prompt + command "set_prompt_response", nil + end + + def modal_message + command "modal_message" + end + end +end diff --git a/lib/capybara/cuprite/browser/client.rb b/lib/capybara/cuprite/browser/client.rb new file mode 100644 index 0000000..4b366f8 --- /dev/null +++ b/lib/capybara/cuprite/browser/client.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "time" + +require "cuprite/browser/web_socket" + +module Capybara::Cuprite + class Browser + class Client + def initialize(host, port) + @host, @port = host, port + @ws = WebSocket.new(ws_url) + end + + def command(method, params = {}) + command_id = @ws.send(method: method, params: params) + message = wait(command: command_id)["result"] + yield message if block_given? + message + rescue DeadClient + restart + raise + end + + def wait(sec = 0.1, command: nil, event: nil, params: {}) + loop do + args = command ? { id: command } : { method: event } + message = @ws.message_by(args) + return message if command && message + return message if event && message && params.all? { |k, v| message["params"][k.to_s] == v } + sleep(sec) + end + end + + private + + def ws_url(try = 0) + @ws_url ||= try(Errno::ECONNREFUSED) do + response = Net::HTTP.get(@host, "/json", @port) + JSON.parse(response)[0]["webSocketDebuggerUrl"] + end + end + + def try(error, attempt = 0, attempts = 5) + yield + rescue error + attempt += 1 + sec = 0.1 + attempt / 10.0 + sleep(sec) and retry if attempt < attempts + end + end + end +end diff --git a/lib/capybara/cuprite/browser/process.rb b/lib/capybara/cuprite/browser/process.rb new file mode 100644 index 0000000..7c074ab --- /dev/null +++ b/lib/capybara/cuprite/browser/process.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "cliver" + +module Capybara::Cuprite + class Browser + class Process + KILL_TIMEOUT = 2 + + BROWSER_PATH = "chrome" + BROWSER_HOST = "127.0.0.1" + BROWSER_PORT = "9222" + + # Chromium command line options + # https://peter.sh/experiments/chromium-command-line-switches/ + DEFAULT_OPTIONS = { + "headless" => true, + "disable-gpu" => true, + "window-size" => "1024,768", + "remote-debugging-port" => BROWSER_PORT, + "remote-debugging-address" => BROWSER_HOST + }.freeze + + def self.start(*args) + new(*args).tap { |s| s.start } + end + + def self.process_killer(pid) + proc do + begin + if Capybara::Cuprite.windows? + ::Process.kill("KILL", pid) + else + ::Process.kill("TERM", pid) + start = Time.now + while ::Process.wait(pid, ::Process::WNOHANG).nil? + sleep 0.05 + next unless (Time.now - start) > KILL_TIMEOUT + ::Process.kill("KILL", pid) + ::Process.wait(pid) + break + end + end + rescue Errno::ESRCH, Errno::ECHILD + end + end + end + + attr_reader :host, :port + + def initialize(options) + @logger = options[:logger] + @path = Cliver.detect(options[:path] || BROWSER_PATH) + + options = options.reject { |k, _| %i(logger path).include?(k) } + @options = DEFAULT_OPTIONS.merge(options) + + @host = @options["remote-debugging-address"] + @port = @options["remote-debugging-port"] + end + + def start + @read_io, @write_io = IO.pipe + + if @logger + @out_thread = Thread.new do + while !@read_io.eof? && (data = @read_io.readpartial(1024)) + @logger.write(data) + end + end + end + + process_options = { in: File::NULL } + process_options[:pgroup] = true unless Capybara::Cuprite.windows? + if Capybara::Cuprite.mri? + process_options[:out] = process_options[:err] = @write_io + end + + redirect_stdout do + cmd = [@path] + @options.map { |k, v| v == true ? "--#{k}" : "--#{k}=#{v}" if v } + @pid = ::Process.spawn(*cmd, process_options) + ObjectSpace.define_finalizer(self, self.class.process_killer(@pid)) + end + end + + def stop + return unless @pid + kill + @out_thread.kill if @logger + close_io + ObjectSpace.undefine_finalizer(self) + end + + def restart + stop + start + end + + private + + def redirect_stdout + if Capybara::Cuprite.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 close_io + [@write_io, @read_io].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/capybara/cuprite/browser/web_socket.rb b/lib/capybara/cuprite/browser/web_socket.rb new file mode 100644 index 0000000..0f7d117 --- /dev/null +++ b/lib/capybara/cuprite/browser/web_socket.rb @@ -0,0 +1,80 @@ +require "json" +require "socket" +require "websocket/driver" + +module Capybara::Cuprite + class Browser + class WebSocket + attr_reader :url, :messages + + def initialize(url) + @url = url + uri = URI.parse(@url) + @sock = TCPSocket.new(uri.host, uri.port) + @driver = ::WebSocket::Driver.client(self) + + @messages = [] + @dead = false + @command_id = 0 + + @driver.on(:message, &method(:on_message)) + @driver.on(:error, &method(:on_error)) + @driver.on(:close, &method(:on_close)) + + Thread.abort_on_exception = true + + @thread = Thread.new do + @driver.parse(@sock.readpartial(512)) until @dead + end + + @driver.start + end + + def send(data) + next_command_id.tap do |id| + data = data.merge(id: id) + json = data.to_json + log ">>> #{json}" + @driver.text(json) + end + end + + def on_message(event) + log "<<< #{event.data}\n\n" + data = JSON.parse(event.data) + raise data["error"]["message"] if data["error"] + @messages << data + end + + def on_error(event) + raise e.message + end + + def on_close(event) + log("<<< #{event.code}, #{event.reason}\n\n") + @dead = true + @thread.kill + end + + def message_by(id: nil, method: nil) + @messages.find do |message| + id ? message["id"] == id : message["method"] == method + end + end + + def write(data) + @sock.write(data) + end + + private + + def next_command_id + @command_id += 1 + end + + def log(message) + puts(message) + end + end + end +end diff --git a/lib/capybara/cuprite/cookie.rb b/lib/capybara/cuprite/cookie.rb new file mode 100644 index 0000000..bdd44c4 --- /dev/null +++ b/lib/capybara/cuprite/cookie.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Capybara::Cuprite + 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 secure? + @attributes["secure"] + end + + def httponly? + @attributes["httponly"] + end + + def samesite + @attributes["samesite"] + end + + def expires + Time.at @attributes["expiry"] if @attributes["expiry"] + end + end +end diff --git a/lib/capybara/cuprite/driver.rb b/lib/capybara/cuprite/driver.rb new file mode 100644 index 0000000..811218c --- /dev/null +++ b/lib/capybara/cuprite/driver.rb @@ -0,0 +1,397 @@ +# frozen_string_literal: true + +require "uri" + +module Capybara::Cuprite + class Driver < Capybara::Driver::Base + DEFAULT_TIMEOUT = 30 + + attr_reader :app + + def initialize(app, options = {}) + @app = app + @options = options.freeze + @started = false + end + + def needs_server? + true + end + + def browser + @browser ||= Browser.start(@options) + end + + def restart + browser.restart + end + + def quit + browser.stop + end + + def visit(url) + @started = true + browser.visit(url) + end + + def current_url + if Capybara::VERSION.to_f < 3.0 + frame_url + else + browser.current_url + end + end + + def frame_url + browser.frame_url + end + + def status_code + browser.status_code + end + + def html + browser.body + end + alias_method :body, :html + + def source + browser.source.to_s + end + + def title + if Capybara::VERSION.to_f < 3.0 + frame_title + else + browser.title + end + end + + def frame_title + browser.frame_title + end + + def find(method, selector) + browser.find(method, selector).map { |page_id, id| Node.new(self, page_id, id) } + end + + def find_xpath(selector) + find :xpath, selector + end + + def find_css(selector) + find :css, selector + end + + def click(x, y) + browser.click_coordinates(x, y) + end + + def evaluate_script(script, *args) + result = browser.evaluate(script, *native_args(args)) + unwrap_script_result(result) + end + + def evaluate_async_script(script, *args) + result = browser.evaluate_async(script, session_wait_time, *native_args(args)) + unwrap_script_result(result) + end + + def execute_script(script, *args) + browser.execute(script, *native_args(args)) + nil + end + + def within_frame(name, &block) + browser.within_frame(name, &block) + end + + def switch_to_frame(locator) + browser.switch_to_frame(locator) + end + + def current_window_handle + browser.window_handle + end + + def window_handles + browser.window_handles + end + + def close_window(handle) + browser.close_window(handle) + end + + def open_new_window + browser.open_new_window + end + + def switch_to_window(handle) + browser.switch_to_window(handle) + end + + def within_window(name, &block) + browser.within_window(name, &block) + end + + def no_such_window_error + NoSuchWindowError + end + + def reset! + browser.reset + browser.url_blacklist = @options[:url_blacklist] if @options.key?(:url_blacklist) + browser.url_whitelist = @options[:url_whitelist] if @options.key?(:url_whitelist) + @started = false + end + + def save_screenshot(path, options = {}) + browser.render(path, options) + end + alias_method :render, :save_screenshot + + def render_base64(format = :png, options = {}) + browser.render_base64(format, options) + end + + def paper_size=(size = {}) + browser.set_paper_size(size) + end + + def zoom_factor=(zoom_factor) + browser.set_zoom_factor(zoom_factor) + end + + def resize(width, height) + browser.resize(width, height) + end + alias_method :resize_window, :resize + + def resize_window_to(handle, width, height) + within_window(handle) do + resize(width, height) + end + end + + def maximize_window(handle) + resize_window_to(handle, *screen_size) + end + + def window_size(handle) + within_window(handle) do + evaluate_script("[window.innerWidth, window.innerHeight]") + end + end + + def scroll_to(left, top) + browser.scroll_to(left, top) + end + + def network_traffic(type = nil) + browser.network_traffic(type) + end + + def clear_network_traffic + browser.clear_network_traffic + end + + def set_proxy(ip, port, type = "http", user = nil, password = nil) + browser.set_proxy(ip, port, type, user, password) + end + + def headers + browser.get_headers + end + + def headers=(headers) + browser.set_headers(headers) + end + + def add_headers(headers) + browser.add_headers(headers) + end + + def add_header(name, value, options = {}) + browser.add_header({ name => value }, { permanent: true }.merge(options)) + end + + def response_headers + browser.response_headers + end + + def cookies + browser.cookies + end + + def set_cookie(name, value, options = {}) + options[:name] ||= name + options[:value] ||= value + options[:domain] ||= begin + if @started + URI.parse(browser.current_url).host + else + URI.parse(default_cookie_host).host || "127.0.0.1" + end + end + + browser.set_cookie(options) + end + + def remove_cookie(name) + browser.remove_cookie(name) + end + + def clear_cookies + browser.clear_cookies + end + + def cookies_enabled=(flag) + browser.cookies_enabled = flag + end + + def clear_memory_cache + browser.clear_memory_cache + end + + # * Browser with set settings does not send `Authorize` on POST request + # * With manually set header browser makes next request with + # `Authorization: Basic Og==` header when settings are empty and the + # response was `401 Unauthorized` (which means Base64.encode64(":")). + # Combining both methods to reach proper behavior. + def basic_authorize(user, password) + browser.set_http_auth(user, password) + credentials = ["#{user}:#{password}"].pack("m*").strip + add_header("Authorization", "Basic #{credentials}") + end + + def pause + # STDIN is not necessarily connected to a keyboard. It might even be closed. + # So we need a method other than keypress to continue. + + # In jRuby - STDIN returns immediately from select + # see https://github.com/jruby/jruby/issues/1783 + read, write = IO.pipe + Thread.new { IO.copy_stream(STDIN, write); write.close } + + STDERR.puts "Cuprite execution paused. Press enter (or run 'kill -CONT #{Process.pid}') to continue." + + signal = false + old_trap = trap("SIGCONT") { signal = true; STDERR.puts "\nSignal SIGCONT received" } + keyboard = IO.select([read], nil, nil, 1) until keyboard || signal # wait for data on STDIN or signal SIGCONT received + + unless signal + begin + input = read.read_nonblock(80) # clear out the read buffer + puts unless input&.end_with?("\n") + rescue EOFError, IO::WaitReadable # Ignore problems reading from STDIN. + end + end + ensure + trap("SIGCONT", old_trap) # Restore the previous signal handler, if there was one. + STDERR.puts "Continuing" + end + + def wait? + true + end + + def invalid_element_errors + [Capybara::Cuprite::ObsoleteNode, Capybara::Cuprite::MouseEventFailed] + end + + def go_back + browser.go_back + end + + def go_forward + browser.go_forward + end + + def refresh + browser.refresh + end + + def accept_modal(type, options = {}) + case type + when :confirm + browser.accept_confirm + when :prompt + browser.accept_prompt options[:with] + end + + yield if block_given? + + find_modal(options) + end + + def dismiss_modal(type, options = {}) + case type + when :confirm + browser.dismiss_confirm + when :prompt + browser.dismiss_prompt + end + + yield if block_given? + find_modal(options) + end + + private + + def native_args(args) + args.map { |arg| arg.is_a?(Capybara::Cuprite::Node) ? arg.native : arg } + end + + def screen_size + @options[:screen_size] || [1366, 768] + end + + def find_modal(options) + start_time = Time.now + 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 = browser.modal_message + raise Capybara::ModalNotFound if modal_text.nil? || (expect_text && !modal_text.match(expect_regexp)) + rescue Capybara::ModalNotFound => e + raise e, not_found_msg if (Time.now - start_time) >= timeout_sec + sleep(0.05) + retry + end + modal_text + end + + def session_wait_time + if respond_to?(:session_options) + session_options.default_max_wait_time + else + begin Capybara.default_max_wait_time rescue Capybara.default_wait_time end + end + end + + def default_cookie_host + if respond_to?(:session_options) + session_options.app_host + else + Capybara.app_host + end || "" + end + + def unwrap_script_result(arg) + case arg + when Array + arg.map { |e| unwrap_script_result(e) } + when Hash + return Capybara::Cuprite::Node.new(self, arg["ELEMENT"]["page_id"], arg["ELEMENT"]["id"]) if arg["ELEMENT"] + arg.each { |k, v| arg[k] = unwrap_script_result(v) } + else + arg + end + end + end +end diff --git a/lib/capybara/cuprite/errors.rb b/lib/capybara/cuprite/errors.rb new file mode 100644 index 0000000..fa41b2a --- /dev/null +++ b/lib/capybara/cuprite/errors.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +module Capybara + module Cuprite + class Error < StandardError; end + class NoSuchWindowError < Error; end + + class ClientError < Error + attr_reader :response + + def initialize(response) + @response = response + end + end + + class JSErrorItem + attr_reader :message, :stack + + def initialize(message, stack) + @message = message + @stack = stack + end + + def to_s + [message, stack].join("\n") + end + end + + class BrowserError < ClientError + def name + response["name"] + end + + def error_parameters + response["args"].join("\n") + end + + def message + "There was an error inside the browser of Cuprite. " \ + "If this is the error returned, and not the cause of a more detailed error response, " \ + "this is probably a bug, so please report it. " \ + "\n\n#{name}: #{error_parameters}" + end + end + + class JavascriptError < ClientError + def javascript_errors + response["args"].first.map { |data| JSErrorItem.new(data["message"], data["stack"]) } + end + + def message + "One or more errors were raised in the Javascript code on the page. " \ + "If you do not care about these errors, you can ignore them by " \ + "setting js_errors: false in your Cuprite configuration (see " \ + "documentation for details)." \ + "\n\n#{javascript_errors.map(&:to_s).join("\n")}" + end + end + + class StatusFailError < ClientError + def url + response["args"].first + end + + def details + response["args"][1] + end + + def message + msg = "Request to "#{url}" failed to reach server, check DNS and/or server status" + msg += " - #{details}" if details + msg + end + end + + class FrameNotFound < ClientError + def name + response["args"].first + end + + def message + "The frame "#{name}" was not found." + end + end + + class InvalidSelector < ClientError + def method + response["args"][0] + end + + def selector + response["args"][1] + end + + def message + "The browser raised a syntax error while trying to evaluate " \ + "#{method} selector #{selector.inspect}" + 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 MouseEventFailed < NodeError + def name + response["args"][0] + end + + def selector + response["args"][1] + end + + def position + [response["args"][2]["x"], response["args"][2]["y"]] + end + + def message + "Firing a #{name} at co-ordinates [#{position.join(", ")}] failed. Cuprite detected " \ + "another element with CSS selector \"#{selector}\" at this position. " \ + "It may be overlapping the element you are trying to interact with. " \ + "If you don't care about overlapping elements, try using node.trigger(\"#{name}\")." + end + end + + class KeyError < ::ArgumentError + def initialize(response) + super(response["args"].first) + end + end + + class TimeoutError < Error + def initialize(message) + @message = message + end + + def message + "Timed out waiting for response to #{@message}. It's possible that this happened " \ + "because something took a very long time (for example a page load was slow). " \ + "If so, setting the Cuprite :timeout option to a higher value will help " \ + "(see the docs for details). If increasing the timeout does not help, this is " \ + "probably a bug in Cuprite - please report it to the issue tracker." + end + end + + class ScriptTimeoutError < Error + def message + "Timed out waiting for evaluated script to resturn a value" + end + end + + class DeadClient < Error + def initialize(message) + @message = message + end + + def message + "Browser client died while processing #{@message}" + end + end + end +end diff --git a/lib/capybara/cuprite/node.rb b/lib/capybara/cuprite/node.rb new file mode 100644 index 0000000..cc69b09 --- /dev/null +++ b/lib/capybara/cuprite/node.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +module Capybara::Cuprite + class Node < Capybara::Driver::Node + attr_reader :page_id, :id + + def initialize(driver, page_id, id) + super(driver, self) + + @page_id = page_id + @id = id + end + + def browser + driver.browser + end + + def command(name, *args) + browser.send(name, page_id, id, *args) + rescue BrowserError => error + case error.name + when "Cuprite.ObsoleteNode" + raise ObsoleteNode.new(self, error.response) + when "Cuprite.MouseEventFailed" + raise MouseEventFailed.new(self, error.response) + else + raise + end + end + + def parents + command(:parents).map { |parent_id| self.class.new(driver, page_id, parent_id) } + end + + def find(method, selector) + command(:find_within, method, selector).map { |id| self.class.new(driver, page_id, id) } + end + + def find_xpath(selector) + find :xpath, selector + end + + def find_css(selector) + find :css, selector + end + + def all_text + filter_text command(:all_text) + end + + def visible_text + if Capybara::VERSION.to_f < 3.0 + filter_text command(:visible_text) + else + command(:visible_text).to_s + .gsub(/\A[[:space:]&&[^\u00a0]]+/, "") + .gsub(/[[:space:]&&[^\u00a0]]+\z/, "") + .gsub(/\n+/, "\n") + .tr("\u00a0", " ") + end + end + + def property(name) + command :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 command(:attribute, name) && command(:property, name) + end + + value = property(name) + value = command(:attribute, name) if value.nil? || value.is_a?(Hash) + + value + end + + def attributes + command :attributes + end + + def value + command :value + end + + def set(value, options = {}) + warn "Options passed to Node#set but Cuprite doesn't currently support any - ignoring" unless options.empty? + + 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 + command :select_file, files + else + command :set, value.to_s + end + elsif tag_name == "textarea" + command :set, value.to_s + elsif self[:isContentEditable] + command :delete_text + send_keys(value.to_s) + end + end + + def select_option + command :select, true + end + + def unselect_option + command(:select, false) || + raise(Capybara::UnselectNotAllowed, "Cannot unselect option from single select box.") + end + + def tag_name + @tag_name ||= command(:tag_name) + end + + def visible? + command :visible? + end + + def checked? + self[:checked] + end + + def selected? + !!self[:selected] + end + + def disabled? + command :disabled? + end + + def click(keys = [], offset = {}) + command :click, keys, offset + end + + def right_click(keys = [], offset = {}) + command :right_click, keys, offset + end + + def double_click(keys = [], offset = {}) + command :double_click, keys, offset + end + + def hover + command :hover + end + + def drag_to(other) + command :drag, other.id + end + + def drag_by(x, y) + command :drag_by, x, y + end + + def trigger(event) + command :trigger, event + end + + def ==(other) + (page_id == other.page_id) && command(:equals, other.id) + end + + def send_keys(*keys) + command :send_keys, keys + end + alias_method :send_key, :send_keys + + def path + command :path + end + + # @api private + def to_json(*) + JSON.generate as_json + end + + # @api private + def as_json(*) + { ELEMENT: { page_id: @page_id, id: @id } } + end + + private + + def filter_text(text) + if Capybara::VERSION.to_f < 3 + Capybara::Helpers.normalize_whitespace(text.to_s) + else + text.gsub(/[\u200b\u200e\u200f]/, "") + .gsub(/[\ \n\f\t\v\u2028\u2029]+/, " ") + .gsub(/\A[[:space:]&&[^\u00a0]]+/, "") + .gsub(/[[:space:]&&[^\u00a0]]+\z/, "") + .tr("\u00a0", " ") + end + end + end +end diff --git a/lib/capybara/cuprite/version.rb b/lib/capybara/cuprite/version.rb new file mode 100644 index 0000000..133101d --- /dev/null +++ b/lib/capybara/cuprite/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Capybara + module Cuprite + VERSION = "0.1.0" + end +end diff --git a/spec/integration/driver_spec.rb b/spec/integration/driver_spec.rb new file mode 100644 index 0000000..94c2a61 --- /dev/null +++ b/spec/integration/driver_spec.rb @@ -0,0 +1,1547 @@ +# frozen_string_literal: true + +require "spec_helper" +require "image_size" +require "pdf/reader" + +module Capybara::Cuprite + describe Driver do + before do + @session = TestSessions::Cuprite + @driver = @session.driver + end + + after { @driver.reset! } + + def session_url(path) + server = @session.server + "http://#{server.host}:#{server.port}#{path}" + end + + it "supports a custom path" do + begin + file = PUMPKINHEAD_ROOT + "/spec/support/custom_chrome_called" + path = PUMPKINHEAD_ROOT + "/spec/support/custom_chrome" + + driver = Driver.new(nil, path: path) + driver.browser + + # 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(file) + driver&.quit + end + end + + context "output redirection" do + let(:logger) { StringIO.new } + let(:session) { Capybara::Session.new(:cuprite_with_logger, TestApp) } + + before do + Capybara.register_driver :cuprite_with_logger do |app| + Capybara::Cuprite::Driver.new(app) + end + end + + after { session.driver.quit } + + it "supports capturing console.log" do + session.visit("/cuprite/console_log") + expect(logger.string).to include("Hello world") + end + + it "is threadsafe in how it captures console.log" do + pending("JRuby and Rubinius do not support the :out parameter to Process.spawn, so there is no threadsafe way to redirect output") unless Capybara::Cuprite.mri? + + # Write something to STDOUT right before Process.spawn is called + allow(Process).to receive(:spawn).and_wrap_original do |m, *args| + STDOUT.puts "1" + $stdout.puts "2" + m.call(*args) + end + + expect do + session.visit("/cuprite/console_log") + end.to output("1\n2\n").to_stdout_from_any_process + + expect(logger.string).not_to match(/\d/) + end + end + + it "raises an error and restarts the client if the client dies while executing a command" do + expect { @driver.browser.command("exit") }.to raise_error(DeadClient) + @session.visit("/") + expect(@driver.html).to include("Hello world") + end + + it "quits silently before visit call" do + driver = Capybara::Cuprite::Driver.new(nil) + expect { driver.quit }.not_to raise_error + end + + it "has a viewport size of 1024x768 by default" do + @session.visit("/") + expect( + @driver.evaluate_script("[window.innerWidth, window.innerHeight]") + ).to eq([1024, 768]) + end + + it "allows the viewport to be resized" do + @session.visit("/") + @driver.resize(200, 400) + expect( + @driver.evaluate_script("[window.innerWidth, window.innerHeight]") + ).to eq([200, 400]) + end + + it "defaults viewport maximization to 1366x768" do + @session.visit("/") + @session.current_window.maximize + expect(@session.current_window.size).to eq([1366, 768]) + end + + it "allows custom maximization size" do + begin + @driver.options[:screen_size] = [1600, 1200] + @session.visit("/") + @session.current_window.maximize + expect(@session.current_window.size).to eq([1600, 1200]) + ensure + @driver.options.delete(:screen_size) + end + end + + it "allows the page to be scrolled" do + @session.visit("/cuprite/long_page") + @driver.resize(10, 10) + @driver.scroll_to(200, 100) + expect( + @driver.evaluate_script("[window.scrollX, window.scrollY]") + ).to eq([200, 100]) + end + + it "supports specifying viewport size with an option" do + begin + Capybara.register_driver :cuprite_with_custom_window_size do |app| + Capybara::Cuprite::Driver.new( + app, + logger: TestSessions.logger, + window_size: [800, 600] + ) + end + driver = Capybara::Session.new(:cuprite_with_custom_window_size, TestApp).driver + driver.visit(session_url("/")) + expect( + driver.evaluate_script("[window.innerWidth, window.innerHeight]") + ).to eq([800, 600]) + ensure + driver&.quit + end + end + + shared_examples "render screen" do + it "supports rendering the whole of a page that goes outside the viewport" do + @session.visit("/cuprite/long_page") + + create_screenshot file + File.open(file, "rb") do |f| + expect(ImageSize.new(f.read).size).to eq( + @driver.evaluate_script("[window.innerWidth, window.innerHeight]") + ) + end + + create_screenshot file, full: true + File.open(file, "rb") do |f| + expect(ImageSize.new(f.read).size).to eq( + @driver.evaluate_script("[document.documentElement.clientWidth, document.documentElement.clientHeight]") + ) + end + end + + it "supports rendering the entire window when documentElement has no height" do + @session.visit("/cuprite/fixed_positioning") + + create_screenshot file, full: true + File.open(file, "rb") do |f| + expect(ImageSize.new(f.read).size).to eq( + @driver.evaluate_script("[window.innerWidth, window.innerHeight]") + ) + end + end + + it "supports rendering just the selected element" do + @session.visit("/cuprite/long_page") + + create_screenshot file, selector: "#penultimate" + + File.open(file, "rb") do |f| + size = @driver.evaluate_script <<-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 + @session.visit("/cuprite/long_page") + expect(@driver.browser).to receive(:warn).with(/Ignoring :selector/) + + create_screenshot file, full: true, selector: "#penultimate" + + File.open(file, "rb") do |f| + expect(ImageSize.new(f.read).size).to eq( + @driver.evaluate_script("[document.documentElement.clientWidth, document.documentElement.clientHeight]") + ) + end + end + + it "resets element positions after" do + @session.visit("cuprite/long_page") + el = @session.find(:css, "#middleish") + # make the page scroll an element into view + el.click + position_script = "document.querySelector("#middleish").getBoundingClientRect()" + offset = @session.evaluate_script(position_script) + create_screenshot file + expect(@session.evaluate_script(position_script)).to eq offset + end + end + + describe "#save_screenshot" do + let(:format) { :png } + let(:file) { PUMPKINHEAD_ROOT + "/spec/tmp/screenshot.#{format}" } + + before(:each) { FileUtils.rm_f file } + + def create_screenshot(file, *args) + @driver.save_screenshot(file, *args) + end + + it "supports rendering the page" do + @session.visit("/") + + @driver.save_screenshot(file) + + expect(File.exist?(file)).to be true + end + + it "supports rendering the page with a nonstring path" do + @session.visit("/") + + @driver.save_screenshot(Pathname(file)) + + expect(File.exist?(file)).to be true + end + + it "supports rendering the page to file without extension when format is specified" do + begin + file = PUMPKINHEAD_ROOT + "/spec/tmp/screenshot" + FileUtils.rm_f file + @session.visit("/") + + @driver.save_screenshot(file, format: "jpg") + + expect(File.exist?(file)).to be true + ensure + FileUtils.rm_f file + end + end + + it "supports rendering the page with different quality settings" do + file2 = PUMPKINHEAD_ROOT + "/spec/tmp/screenshot2.#{format}" + file3 = PUMPKINHEAD_ROOT + "/spec/tmp/screenshot3.#{format}" + FileUtils.rm_f [file2, file3] + + begin + @session.visit("/") + @driver.save_screenshot(file, quality: 0) + @driver.save_screenshot(file2) # defaults to a quality of 75 + @driver.save_screenshot(file3, quality: 100) + expect(File.size(file)).to be < File.size(file2) + 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 + let(:format) { :xbm } + + it "changes image dimensions" do + @session.visit("/cuprite/zoom_test") + + black_pixels_count = lambda { |file| + File.read(file).to_s[/{.*}/m][1...-1].split(/\W/).map { |n| n.hex.to_s(2).count("1") }.reduce(:+) + } + @driver.save_screenshot(file) + before = black_pixels_count[file] + + @driver.zoom_factor = zoom_factor + @driver.save_screenshot(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 + @session.visit("/cuprite/long_page") + @driver.paper_size = { width: "1in", height: "1in" } + + @driver.save_screenshot(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 "render screen" + end + + describe "#render_base64" do + let(:file) { PUMPKINHEAD_ROOT + "/spec/tmp/screenshot.#{format}" } + + def create_screenshot(file, *args) + image = @driver.render_base64(format, *args) + File.open(file, "wb") { |f| f.write Base64.decode64(image) } + end + + it "supports rendering the page in base64" do + @session.visit("/") + + screenshot = @driver.render_base64 + + expect(screenshot.length).to be > 100 + end + + context "png" do + let(:format) { :png } + include_examples "render screen" + end + + context "jpeg" do + let(:format) { :jpeg } + include_examples "render screen" + end + end + + context "setting headers" do + it "allows headers to be set" do + @driver.headers = { + "Cookie" => "foo=bar", + "Host" => "foo.com" + } + @session.visit("/cuprite/headers") + expect(@driver.body).to include("COOKIE: foo=bar") + expect(@driver.body).to include("HOST: foo.com") + end + + it "allows headers to be read" do + expect(@driver.headers).to eq({}) + @driver.headers = { "User-Agent" => "Browser", "Host" => "foo.com" } + expect(@driver.headers).to eq("User-Agent" => "Browser", "Host" => "foo.com") + end + + it "supports User-Agent" do + @driver.headers = { "User-Agent" => "foo" } + @session.visit "/" + expect(@driver.evaluate_script("window.navigator.userAgent")).to eq("foo") + end + + it "sets headers for all HTTP requests" do + @driver.headers = { "X-Omg" => "wat" } + @session.visit "/" + @driver.execute_script <<-JS + var request = new XMLHttpRequest(); + request.open("GET", "/cuprite/headers", false); + request.send(); + + if (request.status === 200) { + document.body.innerHTML = request.responseText; + } + JS + expect(@driver.body).to include("X_OMG: wat") + end + + it "adds new headers" do + @driver.headers = { "User-Agent" => "Browser", "Host" => "foo.com" } + @driver.add_headers("User-Agent" => "Cuprite", "Appended" => "true") + @session.visit("/cuprite/headers") + expect(@driver.body).to include("USER_AGENT: Cuprite") + expect(@driver.body).to include("HOST: foo.com") + expect(@driver.body).to include("APPENDED: true") + end + + it "sets headers on the initial request" do + @driver.headers = { "PermanentA" => "a" } + @driver.add_headers("PermanentB" => "b") + @driver.add_header("Referer", "http://google.com", permanent: false) + @driver.add_header("TempA", "a", permanent: false) + + @session.visit("/cuprite/headers_with_ajax") + initial_request = @session.find(:css, "#initial_request").text + ajax_request = @session.find(: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_not include("TEMPA: a") + end + + it "keeps added headers on redirects by default" do + @driver.add_header("X-Custom-Header", "1", permanent: false) + @session.visit("/cuprite/redirect_to_headers") + expect(@driver.body).to include("X_CUSTOM_HEADER: 1") + end + + it "does not keep added headers on redirect when " \ + "permanent is no_redirect" do + @driver.add_header("X-Custom-Header", "1", permanent: :no_redirect) + + @session.visit("/cuprite/redirect_to_headers") + expect(@driver.body).not_to include("X_CUSTOM_HEADER: 1") + end + + context "multiple windows" do + it "persists headers across popup windows" do + @driver.headers = { + "Cookie" => "foo=bar", + "Host" => "foo.com", + "User-Agent" => "foo" + } + @session.visit("/cuprite/popup_headers") + @session.click_link "pop up" + @session.switch_to_window @session.windows.last + expect(@driver.body).to include("USER_AGENT: foo") + expect(@driver.body).to include("COOKIE: foo=bar") + expect(@driver.body).to include("HOST: foo.com") + end + + it "sets headers in existing windows" do + @session.open_new_window + @driver.headers = { + "Cookie" => "foo=bar", + "Host" => "foo.com", + "User-Agent" => "foo" + } + @session.visit("/cuprite/headers") + expect(@driver.body).to include("USER_AGENT: foo") + expect(@driver.body).to include("COOKIE: foo=bar") + expect(@driver.body).to include("HOST: foo.com") + + @session.switch_to_window @session.windows.last + @session.visit("/cuprite/headers") + expect(@driver.body).to include("USER_AGENT: foo") + expect(@driver.body).to include("COOKIE: foo=bar") + expect(@driver.body).to include("HOST: foo.com") + end + + it "keeps temporary headers local to the current window" do + @session.open_new_window + @driver.add_header("X-Custom-Header", "1", permanent: false) + + @session.switch_to_window @session.windows.last + @session.visit("/cuprite/headers") + expect(@driver.body).not_to include("X_CUSTOM_HEADER: 1") + + @session.switch_to_window @session.windows.first + @session.visit("/cuprite/headers") + expect(@driver.body).to include("X_CUSTOM_HEADER: 1") + end + + it "does not mix temporary headers with permanent ones when propagating to other windows" do + @session.open_new_window + @driver.add_header("X-Custom-Header", "1", permanent: false) + @driver.add_header("Host", "foo.com") + + @session.switch_to_window @session.windows.last + @session.visit("/cuprite/headers") + expect(@driver.body).to include("HOST: foo.com") + expect(@driver.body).not_to include("X_CUSTOM_HEADER: 1") + + @session.switch_to_window @session.windows.first + @session.visit("/cuprite/headers") + expect(@driver.body).to include("HOST: foo.com") + expect(@driver.body).to include("X_CUSTOM_HEADER: 1") + end + + it "does not propagate temporary headers to new windows" do + @session.visit "/" + @driver.add_header("X-Custom-Header", "1", permanent: false) + @session.open_new_window + + @session.switch_to_window @session.windows.last + @session.visit("/cuprite/headers") + expect(@driver.body).not_to include("X_CUSTOM_HEADER: 1") + + @session.switch_to_window @session.windows.first + @session.visit("/cuprite/headers") + expect(@driver.body).to include("X_CUSTOM_HEADER: 1") + end + end + end + + it "supports clicking precise coordinates" do + @session.visit("/cuprite/click_coordinates") + @driver.click(100, 150) + expect(@driver.body).to include("x: 100, y: 150") + end + + it "supports executing multiple lines of javascript" do + @driver.execute_script <<-JS + var a = 1 + var b = 2 + window.result = a + b + JS + expect(@driver.evaluate_script("window.result")).to eq(3) + end + + it "operates a timeout when communicating with browser" do + begin + prev_timeout = @driver.timeout + @driver.timeout = 0.001 + expect { @driver.browser.command "noop" }.to raise_error(TimeoutError) + ensure + @driver.timeout = prev_timeout + end + end + + unless Capybara::Cuprite.windows? + # Not sure how to do this on Windows, so skipping + it "supports quitting the session" do + driver = Capybara::Cuprite::Driver.new(nil) + pid = driver.server_pid + + expect(Process.kill(0, pid)).to eq(1) + driver.quit + + expect { Process.kill(0, pid) }.to raise_error(Errno::ESRCH) + end + end + + context "extending browser javascript" do + before do + @extended_driver = Capybara::Cuprite::Driver.new( + @session.app, + logger: TestSessions.logger, + extensions: %W[#{File.expand_path "../support/geolocation.js", __dir__}] + ) + end + + after do + @extended_driver.quit + end + + it "supports extending the browser's world" do + @extended_driver.visit session_url("/cuprite/requiring_custom_extension") + expect( + @extended_driver.body + ).to include(%(Location: 1,-1)) + expect( + @extended_driver.evaluate_script(%(document.getElementById("location").innerHTML)) + ).to eq("1,-1") + expect( + @extended_driver.evaluate_script("navigator.geolocation") + ).to_not eq(nil) + end + + it "errors when extension is unavailable" do + begin + @failing_driver = Capybara::Cuprite::Driver.new( + @session.app, + logger: TestSessions.logger, + extensions: %W[#{File.expand_path "../support/non_existent.js", __dir__}] + ) + expect { @failing_driver.visit "/" }.to raise_error(Capybara::Cuprite::BrowserError, /Unable to load extension: .*non_existent\.js/) + ensure + @failing_driver.quit + end + end + end + + context "javascript errors" do + it "propagates a Javascript error inside Cuprite to a ruby exception" do + expect do + @driver.browser.command "browser_error" + end.to raise_error(BrowserError) { |e| + expect(e.message).to include("Error: zomg") + # 2.1 refers to files as being in code subdirectory + expect(e.message).to include("compiled/browser.js").or include("code/browser.js") + } + end + + it "propagates an asynchronous Javascript error on the page to a ruby exception" do + expect do + @driver.execute_script "setTimeout(function() { omg }, 0)" + sleep 0.01 + @driver.execute_script "" + end.to raise_error(JavascriptError, /ReferenceError.*omg/) + end + + it "propagates a synchronous Javascript error on the page to a ruby exception" do + expect do + @driver.execute_script "omg" + end.to raise_error(JavascriptError, /ReferenceError.*omg/) + end + + it "does not re-raise a Javascript error if it is rescued" do + expect do + @driver.execute_script "setTimeout(function() { omg }, 0)" + sleep 0.01 + @driver.execute_script "" + end.to raise_error(JavascriptError) + + # should not raise again + expect(@driver.evaluate_script("1+1")).to eq(2) + end + + it "propagates a Javascript error during page load to a ruby exception" do + expect { @session.visit "/cuprite/js_error" }.to raise_error(JavascriptError) + end + + it "does not propagate a Javascript error to ruby if error raising disabled" do + begin + driver = Capybara::Cuprite::Driver.new(@session.app, js_errors: false, logger: TestSessions.logger) + driver.visit session_url("/cuprite/js_error") + driver.execute_script "setTimeout(function() { omg }, 0)" + sleep 0.1 + expect(driver.body).to include("hello") + ensure + driver&.quit + end + end + + it "does not propagate a Javascript error to ruby if error raising disabled and client restarted" do + begin + driver = Capybara::Cuprite::Driver.new(@session.app, js_errors: false, logger: TestSessions.logger) + driver.restart + driver.visit session_url("/cuprite/js_error") + driver.execute_script "setTimeout(function() { omg }, 0)" + sleep 0.1 + expect(driver.body).to include("hello") + ensure + driver&.quit + end + end + + context "Browser page settings" do + it "can override defaults" do + begin + driver = Capybara::Cuprite::Driver.new(@session.app, page_settings: { userAgent: "PageSettingsOverride" }, logger: TestSessions.logger) + driver.visit session_url("/cuprite/headers") + expect(driver.body).to include("USER_AGENT: PageSettingsOverride") + ensure + driver&.quit + end + end + + it "can set resource timeout" do + begin + # If PJS resource timeout is less than drivers timeout it should ignore resources not loading in time + driver = Capybara::Cuprite::Driver.new(@session.app, page_settings: { resourceTimeout: 1000 }, logger: TestSessions.logger) + driver.timeout = 3 + expect do + driver.visit session_url("/cuprite/visit_timeout") + end.not_to raise_error + ensure + driver&.quit + end + end + end + end + + context "browser {'status': 'fail'} responses" do + before { @port = @session.server.port } + + it "do not occur when DNS correct" do + expect { @session.visit("http://localhost:#{@port}/") }.not_to raise_error + end + + it "handles when DNS incorrect" do + expect { @session.visit("http://nope:#{@port}/") }.to raise_error(StatusFailError) + end + + it "has a descriptive message when DNS incorrect" do + url = "http://nope:#{@port}/" + expect do + @session.visit(url) + end.to raise_error(StatusFailError, %(Request to "#{url}" failed to reach server, check DNS and/or server status)) + end + + it "reports open resource requests" do + old_timeout = @session.driver.timeout + begin + @session.driver.timeout = 2 + expect do + @session.visit("/cuprite/visit_timeout") + end.to raise_error(StatusFailError, %r{resources still waiting http://.*/cuprite/really_slow}) + ensure + @session.driver.timeout = old_timeout + end + end + + it "doesnt report open resources where there are none" do + old_timeout = @session.driver.timeout + begin + @session.driver.timeout = 2 + expect do + @session.visit("/cuprite/really_slow") + end.to raise_error(StatusFailError) { |error| + expect(error.message).not_to include("resources still waiting") + } + ensure + @session.driver.timeout = old_timeout + end + end + end + + context "network traffic" do + before do + @driver.restart + end + + it "keeps track of network traffic" do + @session.visit("/cuprite/with_js") + urls = @driver.network_traffic.map(&:url) + + expect(urls.grep(%r{/cuprite/jquery.min.js$}).size).to eq(1) + expect(urls.grep(%r{/cuprite/jquery-ui.min.js$}).size).to eq(1) + expect(urls.grep(%r{/cuprite/test.js$}).size).to eq(1) + end + + it "keeps track of blocked network traffic" do + @driver.browser.url_blacklist = ["unwanted"] + + @session.visit "/cuprite/url_blacklist" + + blocked_urls = @driver.network_traffic(:blocked).map(&:url) + + expect(blocked_urls).to include(/unwanted/) + end + + it "captures responses" do + @session.visit("/cuprite/with_js") + request = @driver.network_traffic.last + + expect(request.response_parts.last.status).to eq(200) + end + + it "captures errors" do + @session.visit("/cuprite/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 + @session.visit("/cuprite/with_js") + expect(@driver.network_traffic.length).to eq(4) + + @session.visit("/cuprite/with_js") + expect(@driver.network_traffic.length).to eq(8) + end + + it "gets cleared on restart" do + @session.visit("/cuprite/with_js") + expect(@driver.network_traffic.length).to eq(4) + + @driver.restart + + @session.visit("/cuprite/with_js") + expect(@driver.network_traffic.length).to eq(4) + end + + it "gets cleared when being cleared" do + @session.visit("/cuprite/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"] + + @session.visit "/cuprite/url_blacklist" + + expect(@driver.network_traffic(:blocked).length).to eq(2) + + @driver.clear_network_traffic + + expect(@driver.network_traffic(:blocked).length).to eq(0) + end + end + + context "memory cache clearing" do + before do + @driver.restart + end + + it "can clear memory cache when supported (>=2.0.0)" do + @driver.clear_memory_cache + + @session.visit("/cuprite/cacheable") + first_request = @driver.network_traffic.last + expect(@driver.network_traffic.length).to eq(1) + expect(first_request.response_parts.last.status).to eq(200) + + @session.visit("/cuprite/cacheable") + expect(@driver.network_traffic.length).to eq(1) + + @driver.clear_memory_cache + + @session.visit("/cuprite/cacheable") + another_request = @driver.network_traffic.last + expect(@driver.network_traffic.length).to eq(2) + expect(another_request.response_parts.last.status).to eq(200) + end + end + + context "status code support" do + it "determines status from the simple response" do + @session.visit("/cuprite/status/500") + expect(@driver.status_code).to eq(500) + end + + it "determines status code when the page has a few resources" do + @session.visit("/cuprite/with_different_resources") + expect(@driver.status_code).to eq(200) + end + + it "determines status code even after redirect" do + @session.visit("/cuprite/redirect") + expect(@driver.status_code).to eq(200) + end + end + + context "cookies support" do + it "returns set cookies" do + @session.visit("/set_cookie") + + cookie = @driver.cookies["capybara"] + expect(cookie.name).to eq("capybara") + expect(cookie.value).to eq("test_cookie") + expect(cookie.domain).to eq("127.0.0.1") + expect(cookie.path).to eq("/") + expect(cookie.secure?).to be false + expect(cookie.httponly?).to be false + expect(cookie.samesite).to be_nil + expect(cookie.expires).to be_nil + end + + it "can set cookies" do + @driver.set_cookie "capybara", "omg" + @session.visit("/get_cookie") + expect(@driver.body).to include("omg") + end + + it "can set cookies with custom settings" do + @driver.set_cookie "capybara", "omg", path: "/cuprite" + + @session.visit("/get_cookie") + expect(@driver.body).to_not include("omg") + + @session.visit("/cuprite/get_cookie") + expect(@driver.body).to include("omg") + + expect(@driver.cookies["capybara"].path).to eq("/cuprite") + end + + it "can remove a cookie" do + @session.visit("/set_cookie") + + @session.visit("/get_cookie") + expect(@driver.body).to include("test_cookie") + + @driver.remove_cookie "capybara" + + @session.visit("/get_cookie") + expect(@driver.body).to_not include("test_cookie") + end + + it "can clear cookies" do + @session.visit("/set_cookie") + + @session.visit("/get_cookie") + expect(@driver.body).to include("test_cookie") + + @driver.clear_cookies + + @session.visit("/get_cookie") + expect(@driver.body).to_not include("test_cookie") + end + + it "can set cookies with an expires time" do + time = Time.at(Time.now.to_i + 10000) + @session.visit "/" + @driver.set_cookie "foo", "bar", expires: time + expect(@driver.cookies["foo"].expires).to eq(time) + end + + it "can set cookies for given domain" do + port = @session.server.port + @driver.set_cookie "capybara", "127.0.0.1" + @driver.set_cookie "capybara", "localhost", domain: "localhost" + + @session.visit("http://localhost:#{port}/cuprite/get_cookie") + expect(@driver.body).to include("localhost") + + @session.visit("http://127.0.0.1:#{port}/cuprite/get_cookie") + expect(@driver.body).to include("127.0.0.1") + end + + it "can enable and disable cookies" do + @driver.cookies_enabled = false + @session.visit("/set_cookie") + expect(@driver.cookies).to be_empty + + @driver.cookies_enabled = true + @session.visit("/set_cookie") + expect(@driver.cookies).to_not be_empty + end + + it "sets cookies correctly when Capybara.app_host is set" do + old_app_host = Capybara.app_host + begin + Capybara.app_host = "http://localhost/cuprite" + @driver.set_cookie "capybara", "app_host" + + port = @session.server.port + @session.visit("http://localhost:#{port}/cuprite/get_cookie") + expect(@driver.body).to include("app_host") + + @session.visit("http://127.0.0.1:#{port}/cuprite/get_cookie") + expect(@driver.body).not_to include("app_host") + ensure + Capybara.app_host = old_app_host + end + end + end + + it "allows the driver to have a fixed port" do + begin + driver = Capybara::Cuprite::Driver.new(@driver.app, port: 12345) + driver.visit session_url("/") + + expect { TCPServer.new("127.0.0.1", 12345) }.to raise_error(Errno::EADDRINUSE) + ensure + driver.quit + 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 + # A custom host and corresponding env var for Travis is specified in .travis.yml + # If var is unspecified, skip test + host = ENV["BROWSER_TEST_HOST"] + + skip "BROWSER_TEST_HOST not set" if host.nil? + + driver = Capybara::Cuprite::Driver.new(@driver.app, host: host, port: 12345) + driver.visit session_url("/") + + expect { TCPServer.new(host, 12345) }.to raise_error(Errno::EADDRINUSE) + ensure + driver&.quit + end + end + + it "lists the open windows" do + @session.visit "/" + + @session.execute_script <<-JS + window.open("/cuprite/simple", "popup") + JS + + expect(@driver.window_handles).to eq(%w[0 1]) + + popup2 = @session.window_opened_by do + @session.execute_script <<-JS + window.open("/cuprite/simple", "popup2") + JS + end + + expect(@driver.window_handles).to eq(%w[0 1 2]) + + @session.within_window(popup2) do + expect(@session.html).to include("Test") + @session.execute_script("window.close()") + end + + sleep 0.1 + + expect(@driver.window_handles).to eq(%w[0 1]) + end + + context "a new window inherits settings" do + it "inherits size" do + @session.visit "/" + @session.current_window.resize_to(1200, 800) + new_tab = @session.open_new_window + expect(new_tab.size).to eq [1200, 800] + end + + it "inherits url_blacklist" do + @driver.browser.url_blacklist = ["unwanted"] + @session.visit "/" + new_tab = @session.open_new_window + @session.within_window(new_tab) do + @session.visit "/cuprite/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.visit "/" + @driver.browser.url_whitelist = ["url_whitelist", "/cuprite/wanted"] + new_tab = @session.open_new_window + @session.within_window(new_tab) do + @session.visit "/cuprite/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.visit "/" + + popup1 = @session.window_opened_by do + @session.execute_script <<-JS + window.open("/cuprite/simple", "popup1") + JS + end + + popup2 = @session.window_opened_by do + @session.execute_script <<-JS + window.open("/cuprite/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 between tests" do + @session.visit "/" + @session.execute_script <<-JS + localStorage.setItem("key", "value"); + JS + value = @session.evaluate_script <<-JS + localStorage.getItem("key"); + JS + + expect(value).to eq("value") + + @driver.reset! + + @session.visit "/" + value = @session.evaluate_script <<-JS + localStorage.getItem("key"); + JS + expect(value).to be_nil + end + + context "basic http authentication" do + it "denies without credentials" do + @session.visit "/cuprite/basic_auth" + + expect(@session.status_code).to eq(401) + expect(@session).not_to have_content("Welcome, authenticated client") + end + + it "allows with given credentials" do + @driver.basic_authorize("login", "pass") + + @session.visit "/cuprite/basic_auth" + + expect(@session.status_code).to eq(200) + expect(@session).to have_content("Welcome, authenticated client") + end + + it "allows even overwriting headers" do + @driver.basic_authorize("login", "pass") + @driver.headers = [{ "Cuprite" => "true" }] + + @session.visit "/cuprite/basic_auth" + + expect(@session.status_code).to eq(200) + expect(@session).to have_content("Welcome, authenticated client") + end + + it "denies with wrong credentials" do + @driver.basic_authorize("user", "pass!") + + @session.visit "/cuprite/basic_auth" + + expect(@session.status_code).to eq(401) + expect(@session).not_to have_content("Welcome, authenticated client") + end + + it "allows on POST request" do + @driver.basic_authorize("login", "pass") + + @session.visit "/cuprite/basic_auth" + @session.click_button("Submit") + + expect(@session.status_code).to eq(200) + expect(@session).to have_content("Authorized POST request") + end + end + + context "blacklisting urls for resource requests" do + it "blocks unwanted urls" do + @driver.browser.url_blacklist = ["unwanted"] + + @session.visit "/cuprite/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"] + + @session.visit "/cuprite/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 "/cuprite/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 "/cuprite/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"] + + @session.visit "/cuprite/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"] + + @session.visit "/cuprite/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"] + + @session.visit "/cuprite/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 = [] + + @session.visit "/cuprite/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", "/cuprite/wanted"])) + end + + session = Capybara::Session.new(:cuprite_whitelist, @session.app) + + session.visit "/cuprite/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 "/cuprite/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 + + context "has ability to send keys" do + before { @session.visit("/cuprite/send_keys") } + + it "sends keys to empty input" do + input = @session.find(:css, "#empty_input") + + input.native.send_keys("Input") + + expect(input.value).to eq("Input") + end + + it "sends keys to filled input" do + input = @session.find(:css, "#filled_input") + + input.native.send_keys(" appended") + + expect(input.value).to eq("Text appended") + end + + it "sends keys to empty textarea" do + input = @session.find(:css, "#empty_textarea") + + input.native.send_keys("Input") + + expect(input.value).to eq("Input") + end + + it "sends keys to filled textarea" do + input = @session.find(:css, "#filled_textarea") + + input.native.send_keys(" appended") + + expect(input.value).to eq("Description appended") + end + + it "sends keys to empty contenteditable div" do + input = @session.find(:css, "#empty_div") + + input.native.send_keys("Input") + + expect(input.text).to eq("Input") + end + + it "persists focus across calls" do + input = @session.find(:css, "#empty_div") + + input.native.send_keys("helo") + input.native.send_keys(:Left) + input.native.send_keys("l") + + expect(input.text).to eq("hello") + end + + it "sends keys to filled contenteditable div" do + input = @session.find(:css, "#filled_div") + + input.native.send_keys(" appended") + + expect(input.text).to eq("Content appended") + end + + it "sends sequences" do + input = @session.find(:css, "#empty_input") + + input.native.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 = @session.find(:css, "#without_submit_button input") + + input.native.send_keys(:Enter) + + expect(input.value).to eq("Submitted") + end + + it "sends sequences with modifiers and letters" do + input = @session.find(:css, "#empty_input") + + input.native.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 = @session.find(:css, "#empty_input") + + input.native.send_keys("t", "r", "i", "n", "g", %i[Ctrl Left], "s") + + expect(input.value).to eq("string") + end + + it "sends sequences with multiple modifiers and symbols" do + input = @session.find(:css, "#empty_input") + + input.native.send_keys("t", "r", "i", "n", "g", %i[Ctrl Shift Left], "s") + + expect(input.value).to eq("s") + end + + it "sends modifiers with sequences" do + input = @session.find(:css, "#empty_input") + + input.native.send_keys("s", [:Shift, "tring"]) + + expect(input.value).to eq("sTRING") + end + + it "sends modifiers with multiple keys" do + input = @session.find(:css, "#empty_input") + + input.native.send_keys("poltre", %i[Shift Left Left], "ergeist") + + expect(input.value).to eq("cuprite") + end + + it "has an alias" do + input = @session.find(:css, "#empty_input") + + input.native.send_key("S") + + expect(input.value).to eq("S") + end + + it "generates correct events with keyCodes for modified puncation" do + input = @session.find(:css, "#empty_input") + + input.send_keys([:shift, "."], [:shift, "t"]) + + expect(@session.find(:css, "#key-events-output")).to have_text("keydown:16 keydown:190 keydown:16 keydown:84") + end + + it "suuports snake_case sepcified keys (Capybara standard)" do + input = @session.find(:css, "#empty_input") + input.send_keys(:PageUp, :page_up) + expect(@session.find(:css, "#key-events-output")).to have_text("keydown:33", count: 2) + end + + it "supports :control alias for :Ctrl" do + input = @session.find(:css, "#empty_input") + input.send_keys([:Ctrl, "a"], [:control, "a"]) + expect(@session.find(:css, "#key-events-output")).to have_text("keydown:17 keydown:65", count: 2) + end + + it "supports :command alias for :Meta" do + input = @session.find(:css, "#empty_input") + input.send_keys([:Meta, "z"], [:command, "z"]) + expect(@session.find(:css, "#key-events-output")).to have_text("keydown:91 keydown:90", count: 2) + end + + it "supports Capybara specified numpad keys" do + input = @session.find(:css, "#empty_input") + input.send_keys(:numpad2, :numpad8, :divide, :decimal) + expect(@session.find(:css, "#key-events-output")).to have_text("keydown:98 keydown:104 keydown:111 keydown:110") + end + + it "raises error for unknown keys" do + input = @session.find(:css, "#empty_input") + expect do + input.send_keys("abc", :blah) + end.to raise_error Capybara::Cuprite::KeyError, "Unknown key: Blah" + end + end + + context "set" do + before { @session.visit("/cuprite/set") } + + it "sets a contenteditable's content" do + input = @session.find(:css, "#filled_div") + input.set("new text") + expect(input.text).to eq("new text") + end + + it "sets multiple contenteditables' content" do + input = @session.find(:css, "#empty_div") + input.set("new text") + + expect(input.text).to eq("new text") + + input = @session.find(:css, "#filled_div") + input.set("replacement text") + + expect(input.text).to eq("replacement text") + end + + it "sets a content editable childs content" do + @session.visit("/with_js") + @session.find(:css, "#existing_content_editable_child").set("WYSIWYG") + expect(@session.find(:css, "#existing_content_editable_child").text).to eq("WYSIWYG") + end + end + + context "date_fields" do + before { @session.visit("/cuprite/date_fields") } + + it "sets a date" do + input = @session.find(:css, "#date_field") + + input.set("2016-02-14") + + expect(input.value).to eq("2016-02-14") + end + + it "fills a date" do + @session.fill_in "date_field", with: "2016-02-14" + + expect(@session.find(:css, "#date_field").value).to eq("2016-02-14") + end + end + + context "evaluate_script" do + it "can return an element" do + @session.visit("/cuprite/send_keys") + element = @session.driver.evaluate_script(%(document.getElementById("empty_input"))) + expect(element).to eq(@session.find(:id, "empty_input").native) + end + + it "can return structures with elements" do + @session.visit("/cuprite/send_keys") + result = @session.driver.evaluate_script(%({ a: document.getElementById("empty_input"), b: { c: document.querySelectorAll("#empty_textarea, #filled_textarea") } })) + expect(result).to eq( + "a" => @session.driver.find_css("#empty_input").first, + "b" => { + "c" => @session.driver.find_css("#empty_textarea, #filled_textarea") + } + ) + end + end + + context "evaluate_async_script" do + it "handles evaluate_async_script value properly" do + @session.using_wait_time(5) do + expect(@session.driver.evaluate_async_script("arguments[0](null)")).to be_nil + expect(@session.driver.evaluate_async_script("arguments[0](false)")).to be false + expect(@session.driver.evaluate_async_script("arguments[0](true)")).to be true + expect(@session.driver.evaluate_async_script(%(arguments[0]({foo: "bar"})))).to eq("foo" => "bar") + end + end + + it "will timeout" do + @session.using_wait_time(1) do + expect do + @session.driver.evaluate_async_script("var callback=arguments[0]; setTimeout(function(){callback(true)}, 4000)") + end.to raise_error Capybara::Cuprite::ScriptTimeoutError + end + end + end + + context "URL" do + it "can get the frames url" do + @session.visit "/cuprite/frames" + + @session.within_frame 0 do + expect(@session.driver.frame_url).to end_with("/cuprite/slow") + if Capybara::VERSION.to_f < 3.0 + expect(@session.driver.current_url).to end_with("/cuprite/slow") + else + # current_url is required to return the top level browsing context in Capybara 3 + expect(@session.driver.current_url).to end_with("/cuprite/frames") + end + end + expect(@session.driver.frame_url).to end_with("/cuprite/frames") + expect(@session.driver.current_url).to end_with("/cuprite/frames") + end + end + end +end diff --git a/spec/integration/session_spec.rb b/spec/integration/session_spec.rb new file mode 100644 index 0000000..04d4ec2 --- /dev/null +++ b/spec/integration/session_spec.rb @@ -0,0 +1,1071 @@ +# frozen_string_literal: true + +require "spec_helper" + +# skip = [] +# skip << :windows if ENV["TRAVIS"] +# skip << :download # Browser doesn't support downloading files +# Capybara::SpecHelper.run_specs TestSessions::Cuprite, "Cuprite", capybara_skip: skip + +describe Capybara::Session do + context "with cuprite driver" do + before { @session = TestSessions::Cuprite } + + describe Capybara::Cuprite::Node do + it "raises an error if the element has been removed from the DOM" do + @session.visit("/cuprite/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(Capybara::Cuprite::ObsoleteNode) + end + + it "raises an error if the element was on a previous page" do + @session.visit("/cuprite/index") + node = @session.find(".//a") + @session.execute_script "window.location = 'about:blank'" + expect { node.text }.to raise_error(Capybara::Cuprite::ObsoleteNode) + end + + it "raises an error if the element is not visible" do + @session.visit("/cuprite/index") + @session.execute_script %(document.querySelector("a[href=js_redirect]").style.display = "none") + expect { @session.click_link "JS redirect" }.to raise_error(Capybara::ElementNotFound) + end + + it "hovers an element" do + @session.visit("/cuprite/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 + @session.visit("/cuprite/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 + @session.visit("/cuprite/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 + @session.visit("/cuprite/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(Capybara::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 + @session.visit("/cuprite/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 + @session.visit("/cuprite/with_js") + end + + it "raises a MouseEventFailed error" do + expect { @session.click_link("O hai") }.to raise_error(Capybara::Cuprite::MouseEventFailed) + end + + context "and is then brought in" do + before do + @session.execute_script "$("#off-the-left").animate({left: "10"});" + Cuprite::SpecHelper.set_capybara_wait_time(1) + end + + it "clicks properly" do + expect { @session.click_link "O hai" }.to_not raise_error + end + + after do + Cuprite::SpecHelper.set_capybara_wait_time(0) + end + end + end + end + + context "when the element is not in the viewport of parent element" do + before do + @session.visit("/cuprite/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 + @session.visit("/cuprite/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 + @session.visit("/cuprite/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 + 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") + end + end + + describe "Node#visible" do + before do + @session.visit("/cuprite/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 + @session.visit "/cuprite/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 + @session.visit "/cuprite/attributes_properties" + end + + it "gets normalized href" do + expect(@session.find(:link, "Loop")["href"]).to eq("http://#{@session.server.host}:#{@session.server.port}/cuprite/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 + @session.visit("/cuprite/simple") + @elem1 = @session.find(:css, "#nav") + @session.visit("/cuprite/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 + @session.visit("/cuprite/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 + @session.visit "/cuprite/simple" + @session.click_link "Link" + expect(@session).to have_content("Hello world") + end + + it "handles clicks where a parent element has a border" do + @session.visit "/cuprite/table" + @session.click_link "Link" + expect(@session).to have_content("Hello world") + end + + it "handles window.confirm(), returning true unconditionally" do + @session.visit "/" + expect(@session.evaluate_script(%(window.confirm("foo")))).to be true + end + + it "handles window.prompt(), returning the default value or null" do + @session.visit "/" + # Disabling because I"m not sure this is really valid + # expect(@session.evaluate_script("window.prompt()")).to be_nil + expect(@session.evaluate_script(%(window.prompt("foo", "default")))).to eq("default") + 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(%({foo: "bar"}))).to eq("foo" => "bar") + end + + it "can evaluate a statement ending with a semicolon" do + expect(@session.evaluate_script("3;")).to eq(3) + end + + it "synchronises page loads properly" do + @session.visit "/cuprite/index" + @session.click_link "JS redirect" + sleep 0.1 + expect(@session.html).to include("Hello world") + end + + context "click tests" do + before do + @session.visit "/cuprite/click_test" + end + + after do + @session.driver.resize(1024, 768) + 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 + @session.visit "/cuprite/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(Capybara::Cuprite::MouseEventFailed) { |error| + expect(error.selector).to eq("html body div#two.box") + expect(error.message).to include("[200, 200]") + } + end + + it "clicks in the centre of an element" do + expect do + @session.find(:css, "#one").click + end.to raise_error(Capybara::Cuprite::MouseEventFailed) { |error| + expect(error.position).to eq([200, 200]) + } + end + + it "clicks in the centre 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(Capybara::Cuprite::MouseEventFailed) { |error| + expect(error.position.first).to eq(150) + } + 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(Capybara::Cuprite::MouseEventFailed) { |error| + expect(error.selector).to eq("html body svg#svg.box") + expect(error.message).to include("[200, 200]") + } + end + end + + context "with image maps" do + before do + @session.visit("/cuprite/image_map") + end + + 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(Capybara::ElementNotFound) + expect do + @session.find(:css, "map[name=testmap2] area[shape=circle]", visible: false).click + end.to raise_error(Capybara::Cuprite::MouseEventFailed) + end + end + end + + context "double click tests" do + before do + @session.visit "/cuprite/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 + @session.visit "/cuprite/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 + @session.visit "/cuprite/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 "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("a" => "(cyclic structure)", "b" => {}, "c" => { "a" => "(cyclic structure)" }) + end + + if Capybara::VERSION.to_f < 3.0 + it "returns BR as a space in #text" do + @session.visit "/cuprite/simple" + expect(@session.find(:css, "#break").text).to eq("Foo Bar") + end + else + it "returns BR as new line in #text" do + @session.visit "/cuprite/simple" + expect(@session.find(:css, "#break").text).to eq("Foo\nBar") + end + end + + it "handles hash changes" do + @session.visit "/#omg" + expect(@session.current_url).to match(%r{/#omg$}) + @session.execute_script <<-JS + window.onhashchange = function() { window.last_hashchange = window.location.hash } + JS + @session.visit "/#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 + @session.visit "/cuprite/arbitrary_path/200/foo%20bar%20baz" + expect(@session.current_path).to eq("/cuprite/arbitrary_path/200/foo%20bar%20baz") + end + + it "supports escaped characters" do + @session.visit "/cuprite/arbitrary_path/200/foo?a%5Bb%5D=c" + expect(request_uri).to eq("/cuprite/arbitrary_path/200/foo?a%5Bb%5D=c") + end + + it "supports url in parameter" do + @session.visit "/cuprite/arbitrary_path/200/foo%20asd?a=http://example.com/asd%20asd" + expect(request_uri).to eq("/cuprite/arbitrary_path/200/foo%20asd?a=http://example.com/asd%20asd") + end + + it "supports restricted characters ' []:/+&='" do + @session.visit "/cuprite/arbitrary_path/200/foo?a=%20%5B%5D%3A%2F%2B%26%3D" + expect(request_uri).to eq("/cuprite/arbitrary_path/200/foo?a=%20%5B%5D%3A%2F%2B%26%3D") + end + + it "returns about:blank when on about:blank" do + @session.visit "about:blank" + expect(@session.current_url).to eq("about:blank") + end + end + + context "dragging support" do + before do + @session.visit "/cuprite/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 + @session.visit "/" + + popup = @session.window_opened_by do + @session.execute_script <<-JS + window.open("/cuprite/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 + @session.visit "/" + + popup = @session.window_opened_by do + @session.execute_script <<-JS + window.open("/cuprite/simple", "popup") + JS + end + + @session.within_window(popup) do + expect(@session.html).to include("Test") + end + popup.close + + another_popup = @session.window_opened_by do + @session.execute_script <<-JS + window.open("/cuprite/simple", "popup") + JS + end + + @session.within_window(another_popup) do + expect(@session.html).to include("Test") + end + another_popup.close + end + end + + context "frame support" do + it "supports selection by index" do + @session.visit "/cuprite/frames" + + @session.within_frame 0 do + expect(@session.driver.frame_url).to end_with("/cuprite/slow") + end + end + + it "supports selection by element" do + @session.visit "/cuprite/frames" + frame = @session.find(:css, "iframe[name]") + + @session.within_frame(frame) do + expect(@session.driver.frame_url).to end_with("/cuprite/slow") + end + end + + it "supports selection by element without name or id" do + @session.visit "/cuprite/frames" + frame = @session.find(:css, "iframe:not([name]):not([id])") + + @session.within_frame(frame) do + expect(@session.driver.frame_url).to end_with("/cuprite/headers") + end + end + + it "supports selection by element with id but no name" do + @session.visit "/cuprite/frames" + frame = @session.find(:css, "iframe[id]:not([name])") + + @session.within_frame(frame) do + expect(@session.driver.frame_url).to end_with("/cuprite/get_cookie") + end + end + + it "waits for the frame to load" do + @session.visit "/" + + @session.execute_script <<-JS + document.body.innerHTML += "