├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── TODO ├── lib ├── hound.ex └── hound │ ├── browser.ex │ ├── browsers │ ├── chrome.ex │ ├── chrome_headless.ex │ ├── default.ex │ ├── firefox.ex │ ├── firefox │ │ └── profile.ex │ └── phantomjs.ex │ ├── connection_server.ex │ ├── element.ex │ ├── exceptions.ex │ ├── helpers.ex │ ├── helpers │ ├── cookie.ex │ ├── dialog.ex │ ├── element.ex │ ├── log.ex │ ├── mouse.ex │ ├── navigation.ex │ ├── orientation.ex │ ├── page.ex │ ├── save_page.ex │ ├── screenshot.ex │ ├── script_execution.ex │ ├── session.ex │ └── window.ex │ ├── internal_helpers.ex │ ├── matchers.ex │ ├── metadata.ex │ ├── request_utils.ex │ ├── response_parser.ex │ ├── response_parsers │ ├── chrome_driver.ex │ ├── phantom_js.ex │ └── selenium.ex │ ├── session.ex │ ├── session_server.ex │ ├── supervisor.ex │ └── utils.ex ├── mix.exs ├── mix.lock ├── notes ├── configuring-hound.md └── simple-browser-automation.md └── test ├── browser_test.exs ├── browsers ├── chrome_headless_test.exs ├── chrome_test.exs ├── default_test.exs ├── firefox │ └── profile_test.exs ├── firefox_test.exs └── phantomjs_test.exs ├── element_test.exs ├── exceptions_test.exs ├── helpers ├── cookie_test.exs ├── dialog_test.exs ├── element_with_ids_test.exs ├── element_with_selectors_test.exs ├── log_test.exs ├── navigation_test.exs ├── orientation_test.exs ├── page_test.exs ├── save_page_test.exs ├── screenshot_test.exs ├── script_execution_test.exs └── window_test.exs ├── hound_test.exs ├── matchers.exs ├── metadata_test.exs ├── multiple_browser_session_test.exs ├── response_parser_test.exs ├── sample_pages ├── iframe.html ├── page1.html ├── page2.html ├── page3.html ├── page_utf.html └── page_with_javascript_error.html ├── session_test.exs ├── test_helper.exs └── tools └── start_webdriver.sh /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | .DS_Store 6 | doc/ 7 | screenshot-*.png 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | elixir: 4 | - 1.4 5 | - 1.5 6 | - 1.6 7 | - 1.7 8 | - 1.8 9 | otp_release: 10 | - 19.3 11 | - 20.3 12 | - 21.0 13 | 14 | env: 15 | - WEBDRIVER=phantomjs 16 | 17 | matrix: 18 | exclude: 19 | - elixir: 1.7 20 | otp_release: 19.3 21 | - elixir: 1.8 22 | otp_release: 19.3 23 | - elixir: 1.4 24 | otp_release: 21 25 | - elixir: 1.5 26 | otp_release: 21 27 | - elixir: 1.6 28 | otp_release: 21 29 | 30 | before_script: 31 | - "export DISPLAY=:99.0" 32 | - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16" 33 | - bash $TRAVIS_BUILD_DIR/test/tools/start_webdriver.sh 34 | 35 | script: mix test --exclude=issue_travis_${WEBDRIVER} 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Akash Manohar J 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hound 2 | 3 | For browser automation and writing integration tests in Elixir. 4 | 5 | Source | Documentation 6 | 7 | [![Build Status](https://travis-ci.org/HashNuke/hound.png?branch=master)](https://travis-ci.org/HashNuke/hound) 8 | 9 | ## Features 10 | 11 | * Can run __multiple browser sessions__ simultaneously. [See example](https://github.com/HashNuke/hound/blob/master/test/multiple_browser_session_test.exs). 12 | 13 | * Supports Selenium (Firefox, Chrome), ChromeDriver and PhantomJs. 14 | 15 | * Supports Javascript-heavy apps. Retries a few times before reporting error. 16 | 17 | * Implements the WebDriver Wire Protocol. 18 | 19 | **Internet Explorer may work under Selenium, but hasn't been tested.** 20 | 21 | #### Example 22 | 23 | ##### ExUnit example 24 | 25 | ```elixir 26 | defmodule HoundTest do 27 | use ExUnit.Case 28 | use Hound.Helpers 29 | 30 | hound_session() 31 | 32 | test "the truth", meta do 33 | navigate_to("http://example.com/guestbook.html") 34 | 35 | element = find_element(:name, "message") 36 | fill_field(element, "Happy Birthday ~!") 37 | submit_element(element) 38 | 39 | assert page_title() == "Thank you" 40 | end 41 | 42 | end 43 | ``` 44 | 45 | Here's another [simple browser-automation example](https://github.com/HashNuke/hound/blob/master/notes/simple-browser-automation.md). 46 | 47 | ## Setup 48 | 49 | Hound requires Elixir 1.0.4 or higher. 50 | 51 | * Add dependency to your mix project 52 | 53 | ```elixir 54 | 55 | {:hound, "~> 1.0"} 56 | ``` 57 | 58 | * Start Hound in your `test/test_helper.exs` file **before** the `ExUnit.start()` line: 59 | 60 | ```elixir 61 | Application.ensure_all_started(:hound) 62 | ExUnit.start() 63 | ``` 64 | 65 | When you run `mix test`, Hound is automatically started. __You'll need a webdriver server__ running, like Selenium Server or Chrome Driver. If you aren't sure what it is, then [read this](https://github.com/HashNuke/hound/wiki/Starting-a-webdriver-server). 66 | 67 | #### If you're using Phoenix 68 | Ensure the server is started when your tests are run. In `config/test.exs` change the `server` option of your endpoint config to `true`: 69 | 70 | ```elixir 71 | config :hello_world_web, HelloWorldWeb.Endpoint, 72 | http: [port: 4001], 73 | server: true 74 | ``` 75 | 76 | ## Configure 77 | 78 | To configure Hound, use your `config/config.exs` file or equivalent. 79 | 80 | Example: 81 | 82 | ```config :hound, driver: "phantomjs"``` 83 | 84 | [More examples here](https://github.com/HashNuke/hound/blob/master/notes/configuring-hound.md). 85 | 86 | ## Usage 87 | 88 | Add the following lines to your ExUnit test files. 89 | 90 | ```elixir 91 | # Import helpers 92 | use Hound.Helpers 93 | 94 | # Start hound session and destroy when tests are run 95 | hound_session() 96 | ``` 97 | 98 | If you prefer to manually start and end sessions, use `Hound.start_session` and `Hound.end_session` in the setup and teardown blocks of your tests. 99 | 100 | 101 | ## Helpers 102 | 103 | The documentation pages include examples under each function. 104 | 105 | * [Navigation](http://hexdocs.pm/hound/Hound.Helpers.Navigation.html) 106 | * [Page](http://hexdocs.pm/hound/Hound.Helpers.Page.html) 107 | * [Element](http://hexdocs.pm/hound/Hound.Helpers.Element.html) 108 | * [Cookies](http://hexdocs.pm/hound/Hound.Helpers.Cookie.html) 109 | * [Javascript execution](http://hexdocs.pm/hound/Hound.Helpers.ScriptExecution.html) 110 | * [Javascript dialogs](http://hexdocs.pm/hound/Hound.Helpers.Dialog.html) 111 | * [Screenshot](http://hexdocs.pm/hound/Hound.Helpers.Screenshot.html) 112 | * [Session](http://hexdocs.pm/hound/Hound.Helpers.Session.html) 113 | * [Window](http://hexdocs.pm/hound/Hound.Helpers.Window.html) 114 | 115 | The docs are at . 116 | 117 | ### More examples? [Checkout Hound's own test cases](https://github.com/HashNuke/hound/tree/master/test/helpers) 118 | 119 | ## FAQ 120 | 121 | #### Can I run multiple browser sessions simultaneously 122 | 123 | Oh yeah ~! [Here is an example](https://github.com/HashNuke/hound/blob/master/test/multiple_browser_session_test.exs). 124 | 125 | If you are running PhantomJs, take a look at the *Caveats* section below. 126 | 127 | #### Can I run tests async? 128 | 129 | Yes. 130 | 131 | The number of tests you can run async at any point in time, depends on the number of sessions that your webdriver can maintain at a time. For Selenium Standalone, there seems to be a default limit of 15 sessions. You can set ExUnit's async option to limit the number of tests to run parallelly. 132 | 133 | #### Will Hound guarantee an isolated session per test? 134 | 135 | Yes. A separate session is started for each test process. 136 | 137 | ## PhantomJs caveats 138 | 139 | PhantomJs is extremely fast, but there are certain caveats. It uses Ghostdriver for its webdriver server, which currently has unimplemented features or open issues. 140 | 141 | * Cookie jar isn't separate for sessions - 142 | Which means all sessions share the same cookies. Make sure you run `delete_cookies()` at the end of each test. 143 | * Isolated sessions were added to GhostDriver recently and are yet to land in a PhantomJs release. 144 | * Javascript alerts aren't yet supported - . 145 | 146 | ## Running tests 147 | 148 | You need a webdriver in order to run tests. We recommend `phantomjs` but any can be used by setting the WEBDRIVER environment variable as shown below: 149 | 150 | $ phantomjs --wd 151 | $ WEBDRIVER=phantomjs mix test 152 | 153 | ## Maintainers 154 | 155 | * Akash Manohar ([HashNuke](https://github.com/HashNuke)) 156 | * Daniel Perez ([tuvistavie](https://github.com/tuvistavie)) 157 | 158 | ## Customary proclamation... 159 | 160 | Copyright © 2013-2015, Akash Manohar J, under the MIT License (basically, do whatever you want) 161 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * alias start_session and end_session to Hound.Session helpers 2 | * find_element and find_all_element: is it timeout_in_seconds or retries 3 | 4 | * Window size, position, maximize, close window, focus frame 5 | * Mouse 6 | * Geo location 7 | * Local storage 8 | * Session storage 9 | * Session log 10 | * Application cache status 11 | 12 | 13 | # Just using this as a dumping ground 14 | defmodule Hound.JsonDriver.Ime do 15 | @moduledoc false 16 | # import Hound.JsonDriver.Utils 17 | 18 | # @doc "List available IME engines" 19 | # @spec available_ime_engines() :: Dict.t 20 | # def available_ime_engines() 21 | 22 | # @doc "Get name of active IME engine" 23 | # @spec active_ime_engine() :: String.t 24 | # def active_ime_engine() 25 | 26 | # @doc "Checks if the IME input is currently active" 27 | # @spec ime_active?() :: Boolean.t 28 | # def ime_active?() 29 | 30 | # @doc "Activate IME engine" 31 | # @spec activate_ime_engine(String.t) :: :ok 32 | # def activate_ime_engine(engine_name) 33 | 34 | # @doc "Deactivate currently active IME engine" 35 | # @spec deactivate_current_img_engine() :: :ok 36 | # def deactivate_current_ime_engine() 37 | end 38 | -------------------------------------------------------------------------------- /lib/hound.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/Application.Behaviour.html 5 | # for more information on OTP Applications 6 | @doc false 7 | def start(_type, _args) do 8 | Hound.Supervisor.start_link 9 | end 10 | 11 | 12 | @doc false 13 | def driver_info do 14 | Hound.ConnectionServer.driver_info 15 | end 16 | 17 | @doc false 18 | def configs do 19 | Hound.ConnectionServer.configs 20 | end 21 | 22 | 23 | @doc "See `Hound.Helpers.Session.start_session/1`" 24 | defdelegate start_session, to: Hound.Helpers.Session 25 | defdelegate start_session(opts), to: Hound.Helpers.Session 26 | 27 | @doc "See `Hound.Helpers.Session.end_session/1`" 28 | defdelegate end_session, to: Hound.Helpers.Session 29 | defdelegate end_session(pid), to: Hound.Helpers.Session 30 | 31 | @doc false 32 | defdelegate current_session_id, to: Hound.Helpers.Session 33 | end 34 | -------------------------------------------------------------------------------- /lib/hound/browser.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser do 2 | @moduledoc "Low level functions to customize browser behavior" 3 | 4 | @type t :: Hound.BrowserLike.t 5 | 6 | @callback default_user_agent :: String.t | atom 7 | 8 | @callback default_capabilities(String.t) :: map 9 | 10 | @doc "Creates capabilities for the browser and options, to be sent to the webdriver" 11 | @spec make_capabilities(t, map | Keyword.t) :: map 12 | def make_capabilities(browser_name, opts \\ []) do 13 | browser = browser(browser_name) 14 | 15 | user_agent = 16 | user_agent(opts[:user_agent] || browser.default_user_agent) 17 | |> Hound.Metadata.append(opts[:metadata]) 18 | 19 | capabilities = %{browserName: browser_name} 20 | default_capabilities = browser.default_capabilities(user_agent) 21 | additional_capabilities = opts[:additional_capabilities] || %{} 22 | 23 | capabilities 24 | |> Map.merge(default_capabilities) 25 | |> Map.merge(additional_capabilities) 26 | end 27 | 28 | @doc "Returns a user agent string" 29 | @spec user_agent(String.t | atom) :: String.t 30 | def user_agent(ua) when is_binary(ua), do: ua 31 | 32 | # bundle a few common user agents 33 | def user_agent(:firefox_desktop) do 34 | "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1" 35 | end 36 | def user_agent(:phantomjs) do 37 | "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1" 38 | end 39 | def user_agent(:chrome_desktop) do 40 | "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36" 41 | end 42 | def user_agent(:chrome_android_sp) do 43 | "Mozilla/5.0 (Linux; U; Android-4.0.3; en-us; Galaxy Nexus Build/IML74K) AppleWebKit/535.7 (KHTML, like Gecko) CrMo/16.0.912.75 Mobile Safari/535.7" 44 | end 45 | def user_agent(:chrome_iphone) do 46 | "Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3" 47 | end 48 | def user_agent(:safari_iphone) do 49 | "Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25" 50 | end 51 | def user_agent(:default) do 52 | "" 53 | end 54 | 55 | # add some simpler aliases 56 | def user_agent(:chrome), do: user_agent(:chrome_desktop) 57 | def user_agent(:firefox), do: user_agent(:firefox_desktop) 58 | def user_agent(:android), do: user_agent(:chrome_android_sp) 59 | def user_agent(:iphone), do: user_agent(:safari_iphone) 60 | 61 | defp browser(browser) when is_atom(browser) do 62 | browser |> Atom.to_string |> browser() 63 | end 64 | defp browser("firefox"), do: Hound.Browser.Firefox 65 | defp browser("chrome"), do: Hound.Browser.Chrome 66 | defp browser("chrome_headless"), do: Hound.Browser.ChromeHeadless 67 | defp browser("phantomjs"), do: Hound.Browser.PhantomJS 68 | defp browser(_), do: Hound.Browser.Default 69 | end 70 | -------------------------------------------------------------------------------- /lib/hound/browsers/chrome.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.Chrome do 2 | @moduledoc false 3 | 4 | @behaviour Hound.Browser 5 | 6 | def default_user_agent, do: :chrome 7 | 8 | def default_capabilities(ua) do 9 | %{chromeOptions: %{"args" => ["--user-agent=#{ua}"]}} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/hound/browsers/chrome_headless.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.ChromeHeadless do 2 | @moduledoc false 3 | 4 | @behaviour Hound.Browser 5 | 6 | def default_user_agent, do: :chrome 7 | 8 | def default_capabilities(ua) do 9 | %{chromeOptions: %{"args" => ["--user-agent=#{ua}", "--headless", "--disable-gpu"]}} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/hound/browsers/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.Default do 2 | @moduledoc false 3 | 4 | @behaviour Hound.Browser 5 | 6 | def default_user_agent, do: :default 7 | 8 | def default_capabilities(_ua), do: %{} 9 | end 10 | -------------------------------------------------------------------------------- /lib/hound/browsers/firefox.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.Firefox do 2 | @moduledoc false 3 | 4 | @behaviour Hound.Browser 5 | 6 | alias Hound.Browser.Firefox.Profile 7 | 8 | def default_user_agent, do: :firefox 9 | 10 | def default_capabilities(ua) do 11 | {:ok, profile} = Profile.new |> Profile.set_user_agent(ua) |> Profile.dump 12 | %{firefox_profile: profile} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/hound/browsers/firefox/profile.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.Firefox.Profile do 2 | @moduledoc false 3 | 4 | @default_prefs %{ 5 | } 6 | 7 | defstruct [prefs: @default_prefs] 8 | 9 | def new do 10 | %__MODULE__{} 11 | end 12 | 13 | def get_preference(%__MODULE__{prefs: prefs}, key) do 14 | Map.get(prefs, key) 15 | end 16 | 17 | def put_preference(profile, key, value) do 18 | %{profile | prefs: Map.put(profile.prefs, key, value)} 19 | end 20 | 21 | def set_user_agent(profile, user_agent) do 22 | put_preference(profile, "general.useragent.override", user_agent) 23 | end 24 | 25 | def serialize_preferences(profile) do 26 | profile.prefs 27 | |> Enum.map_join("\n", fn {key, value} -> 28 | ~s[user_pref("#{key}", #{Jason.encode!(value)});] 29 | end) 30 | end 31 | 32 | def dump(profile) do 33 | files = [{'user.js', serialize_preferences(profile)}] 34 | case :zip.create('profile.zip', files, [:memory]) do 35 | {:ok, {_filename, binary}} -> 36 | {:ok, Base.encode64(binary)} 37 | {:error, _reason} = error -> 38 | error 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/hound/browsers/phantomjs.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.PhantomJS do 2 | @moduledoc false 3 | 4 | @behaviour Hound.Browser 5 | 6 | def default_user_agent, do: :phantomjs 7 | 8 | def default_capabilities(ua) do 9 | %{"phantomjs.page.settings.userAgent" => ua} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/hound/connection_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.ConnectionServer do 2 | @moduledoc false 3 | 4 | def start_link(options \\ []) do 5 | driver = options[:driver] || Application.get_env(:hound, :driver, "selenium") 6 | 7 | {default_port, default_path_prefix, default_browser} = case driver do 8 | "chrome_driver" -> 9 | {9515, nil, "chrome"} 10 | "phantomjs" -> 11 | {8910, nil, "phantomjs"} 12 | _ -> # assume selenium 13 | {4444, "wd/hub/", "firefox"} 14 | end 15 | 16 | 17 | browser = options[:browser] || Application.get_env(:hound, :browser, default_browser) 18 | host = options[:host] || Application.get_env(:hound, :host, "http://localhost") 19 | port = options[:port] || Application.get_env(:hound, :port, default_port) 20 | path_prefix = options[:path_prefix] || Application.get_env(:hound, :path_prefix, default_path_prefix) 21 | 22 | 23 | driver_info = %{ 24 | :driver => driver, 25 | :browser => browser, 26 | :host => host, 27 | :port => port, 28 | :path_prefix => path_prefix 29 | } 30 | 31 | configs = %{ 32 | :host => options[:app_host] || Application.get_env(:hound, :app_host, "http://localhost"), 33 | :port => options[:app_port] || Application.get_env(:hound, :app_port, 4001), 34 | :temp_dir => options[:temp_dir] || Application.get_env(:hound, :temp_dir, File.cwd!) 35 | } 36 | 37 | state = %{sessions: [], driver_info: driver_info, configs: configs} 38 | Agent.start_link(fn -> state end, name: __MODULE__) 39 | end 40 | 41 | def driver_info do 42 | {:ok, Agent.get(__MODULE__, &(&1.driver_info), 60000)} 43 | end 44 | 45 | def configs do 46 | {:ok, Agent.get(__MODULE__, &(&1.configs), 60000)} 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/hound/element.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Element do 2 | @moduledoc """ 3 | A representation of a web element 4 | """ 5 | 6 | defstruct uuid: nil 7 | @type t :: %__MODULE__{uuid: String.t} 8 | 9 | @type strategy :: :css | :class | :id | :name | :tag | :xpath | :link_text | :partial_link_text 10 | @type matcher :: {strategy, String.t} 11 | @type selector :: t | matcher 12 | 13 | @doc """ 14 | Returns true if the argument is an Element 15 | """ 16 | @spec element?(any) :: boolean 17 | def element?(%__MODULE__{}), do: true 18 | def element?(_), do: false 19 | 20 | @doc """ 21 | Returns an element from a driver element response 22 | """ 23 | @spec from_response(map) :: t 24 | def from_response(element) when is_map(element) do 25 | element |> Map.to_list |> from_response 26 | end 27 | def from_response([{"ELEMENT", uuid}]), do: %__MODULE__{uuid: uuid} 28 | def from_response([{"element-" <> _id, uuid}]), do: %__MODULE__{uuid: uuid} 29 | def from_response(value), do: raise Hound.InvalidElementError, value: value 30 | end 31 | 32 | defimpl Jason.Encoder, for: Hound.Element do 33 | def encode(%{uuid: uuid}, options) do 34 | Jason.Encode.map(%{"ELEMENT" => uuid}, options) 35 | end 36 | end 37 | 38 | defimpl String.Chars, for: Hound.Element do 39 | def to_string(elem) do 40 | elem.uuid 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/hound/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Error do 2 | defexception [:message] 3 | end 4 | 5 | defmodule Hound.NoSuchElementError do 6 | defexception [:strategy, :selector, :parent] 7 | 8 | def message(err) do 9 | parent_text = if err.parent, do: "in #{err.parent} ", else: "" 10 | "No element #{parent_text}found for #{err.strategy} '#{err.selector}'" 11 | end 12 | end 13 | 14 | defmodule Hound.InvalidElementError do 15 | defexception [:value] 16 | 17 | def message(err) do 18 | "Could not transform value #{inspect(err.value)} to element" 19 | end 20 | end 21 | 22 | defmodule Hound.NotSupportedError do 23 | defexception [:function, :browser, :driver] 24 | 25 | def message(err) do 26 | %{browser: browser, driver: driver} = driver_info(err) 27 | "#{err.function} is not supported by driver #{driver} with browser #{browser}" 28 | end 29 | 30 | @doc "Raises an exception if the given parameters match the current driver" 31 | defmacro raise_for(params) do 32 | function = case __CALLER__.function do 33 | {func, arity} -> "#{func}/#{arity}" 34 | func -> to_string(func) 35 | end 36 | quote bind_quoted: binding() do 37 | Hound.NotSupportedError.raise_for(params, function) 38 | end 39 | end 40 | 41 | @doc "Same as raise_for/1 but accepts a function name to customize the error output" 42 | def raise_for(params, function) when is_map(params) do 43 | {:ok, info} = Hound.ConnectionServer.driver_info 44 | if Map.take(info, Map.keys(params)) == params do 45 | raise __MODULE__, function: function, browser: info.browser, driver: info.driver 46 | end 47 | end 48 | 49 | defp driver_info(err) do 50 | if err.browser && err.driver do 51 | Map.take(err, [:browser, :driver]) 52 | else 53 | {:ok, info} = Hound.ConnectionServer.driver_info 54 | %{driver: err.driver || info.driver, browser: err.browser || info.browser} 55 | end 56 | end 57 | end 58 | 59 | defmodule Hound.InvalidMetadataError do 60 | defexception [:value] 61 | 62 | def message(err) do 63 | "could not parse metadata for value #{err.value}" 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/hound/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers do 2 | @moduledoc false 3 | 4 | defmacro __using__([]) do 5 | quote do 6 | import Hound 7 | import Hound.Helpers.Cookie 8 | import Hound.Helpers.Dialog 9 | import Hound.Helpers.Element 10 | import Hound.Helpers.Navigation 11 | import Hound.Helpers.Orientation 12 | import Hound.Helpers.Page 13 | import Hound.Helpers.Screenshot 14 | import Hound.Helpers.SavePage 15 | import Hound.Helpers.ScriptExecution 16 | import Hound.Helpers.Session 17 | import Hound.Helpers.Window 18 | import Hound.Helpers.Log 19 | import Hound.Helpers.Mouse 20 | import Hound.Matchers 21 | import unquote(__MODULE__) 22 | end 23 | end 24 | 25 | 26 | defmacro hound_session(opts \\ []) do 27 | quote do 28 | setup do 29 | Hound.start_session(unquote(opts)) 30 | parent = self() 31 | on_exit(fn -> Hound.end_session(parent) end) 32 | 33 | :ok 34 | end 35 | end 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /lib/hound/helpers/cookie.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Cookie do 2 | @moduledoc "Cookie-related functions" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc """ 7 | Gets cookies. Returns a list of ListDicts, each containing properties of the cookie. 8 | 9 | cookies() 10 | """ 11 | @spec cookies() :: list 12 | def cookies() do 13 | session_id = Hound.current_session_id 14 | make_req(:get, "session/#{session_id}/cookie") 15 | end 16 | 17 | 18 | @doc """ 19 | Sets cookie. 20 | 21 | set_cookie(%{name: "cart_id", value: 123213}) 22 | set_cookie(%{name: "cart_id", value: "23fa0ev5a6er", secure: true}) 23 | 24 | Accepts a Map with the following keys: 25 | 26 | * name (string) - REQUIRED 27 | * value (string) - REQUIRED 28 | * path (string) 29 | * domain (string) 30 | * secure (boolean) 31 | * expiry (integer, specified in seconds since midnight, January 1, 1970 UTC) 32 | """ 33 | @spec set_cookie(map) :: :ok 34 | def set_cookie(cookie) do 35 | session_id = Hound.current_session_id 36 | make_req(:post, "session/#{session_id}/cookie", %{cookie: cookie}) 37 | end 38 | 39 | 40 | @doc "Delete all cookies" 41 | @spec delete_cookies() :: :ok 42 | def delete_cookies() do 43 | session_id = Hound.current_session_id 44 | make_req(:delete, "session/#{session_id}/cookie") 45 | end 46 | 47 | 48 | @doc "Delete a cookie with the given name" 49 | @spec delete_cookie(String.t) :: :ok 50 | def delete_cookie(name) do 51 | session_id = Hound.current_session_id 52 | make_req(:delete, "session/#{session_id}/cookie/#{name}") 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /lib/hound/helpers/dialog.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Dialog do 2 | @moduledoc "Functions to handle Javascript dialogs alert(), prompt() and confirm()" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc """ 7 | Gets text of a javascript alert(), confirm() or prompt(). 8 | 9 | dialog_text() 10 | """ 11 | @spec dialog_text() :: String.t 12 | def dialog_text do 13 | session_id = Hound.current_session_id 14 | make_req(:get, "session/#{session_id}/alert_text") 15 | end 16 | 17 | 18 | @doc """ 19 | Inputs text to a javascript prompt(). 20 | 21 | input_into_prompt("John Doe") 22 | """ 23 | @spec input_into_prompt(String.t) :: :ok 24 | def input_into_prompt(input) do 25 | session_id = Hound.current_session_id 26 | make_req(:post, "session/#{session_id}/alert_text", %{text: input}) 27 | end 28 | 29 | 30 | @doc """ 31 | Accepts javascript dialog. 32 | 33 | accept_dialog() 34 | """ 35 | @spec accept_dialog() :: :ok 36 | def accept_dialog do 37 | session_id = Hound.current_session_id 38 | make_req(:post, "session/#{session_id}/accept_alert") 39 | end 40 | 41 | 42 | @doc """ 43 | Dismiss a javascript dialog. 44 | 45 | dismiss_dialog() 46 | """ 47 | @spec dismiss_dialog() :: :ok 48 | def dismiss_dialog do 49 | session_id = Hound.current_session_id 50 | make_req(:post, "session/#{session_id}/dismiss_alert", %{}) 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /lib/hound/helpers/element.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Element do 2 | @moduledoc "Functions to work with an element" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc """ 7 | Gets visible text of element. Requires the element. 8 | 9 | element = find_element(:css, ".example") 10 | visible_text(element) 11 | 12 | You can also directly pass the selector as a tuple. 13 | 14 | visible_text({:css, ".example"}) 15 | """ 16 | @spec visible_text(Hound.Element.selector) :: String.t 17 | def visible_text(element) do 18 | element = get_element(element) 19 | session_id = Hound.current_session_id 20 | make_req(:get, "session/#{session_id}/element/#{element}/text") 21 | end 22 | 23 | 24 | @spec inner_html(Hound.Element.selector) :: String.t 25 | def inner_html(element) do 26 | attribute_value(element, "innerHTML") 27 | end 28 | 29 | 30 | @spec inner_text(Hound.Element.selector) :: String.t 31 | def inner_text(element) do 32 | attribute_value(element, "innerText") 33 | end 34 | 35 | @spec outer_html(Hound.Element.selector) :: String.t 36 | def outer_html(element) do 37 | attribute_value(element, "outerHTML") 38 | end 39 | 40 | @doc """ 41 | Enters value into field. 42 | 43 | It does not clear the field before entering the new value. Anything passed is added to the value already present. 44 | 45 | element = find_element(:id, "example") 46 | input_into_field(element, "John Doe") 47 | 48 | You can also pass the selector as a tuple, for the first argument. 49 | 50 | input_into_field({:id, "example"}, "John Doe") 51 | """ 52 | @spec input_into_field(Hound.Element.selector, String.t) :: :ok 53 | def input_into_field(element, input) do 54 | element = get_element(element) 55 | session_id = Hound.current_session_id 56 | make_req(:post, "session/#{session_id}/element/#{element}/value", %{value: ["#{input}"]}) 57 | end 58 | 59 | 60 | @doc """ 61 | Sets a field's value. The difference with `input_into_field` is that, the field is cleared before entering the new value. 62 | 63 | element = find_element(:id, "example") 64 | fill_field(element, "John Doe") 65 | 66 | You can also pass the selector as a tuple, for the first argument. 67 | 68 | fill_field({:id, "example"}, "John Doe") 69 | """ 70 | @spec fill_field(Hound.Element.selector, String.t) :: :ok 71 | def fill_field(element, input) do 72 | element = get_element(element) 73 | session_id = Hound.current_session_id 74 | clear_field(element) 75 | make_req(:post, "session/#{session_id}/element/#{element}/value", %{value: ["#{input}"]}) 76 | end 77 | 78 | 79 | @doc """ 80 | Gets an element's tag name. 81 | 82 | element = find_element(:class, "example") 83 | tag_name(element) 84 | 85 | You can also directly pass the selector as a tuple. 86 | 87 | tag_name({:class, "example"}) 88 | """ 89 | @spec tag_name(Hound.Element.selector) :: String.t 90 | def tag_name(element) do 91 | element = get_element(element) 92 | session_id = Hound.current_session_id 93 | make_req(:get, "session/#{session_id}/element/#{element}/name") 94 | end 95 | 96 | 97 | @doc """ 98 | Clears textarea or input field's value 99 | 100 | element = find_element(:class, "example") 101 | clear_field(element) 102 | 103 | You can also directly pass the selector as a tuple. 104 | 105 | clear_field({:class, "example"}) 106 | """ 107 | @spec clear_field(Hound.Element.selector) :: :ok 108 | def clear_field(element) do 109 | element = get_element(element) 110 | session_id = Hound.current_session_id 111 | make_req(:post, "session/#{session_id}/element/#{element}/clear") 112 | end 113 | 114 | 115 | @doc """ 116 | Checks if a radio input group or checkbox has any value selected. 117 | 118 | element = find_element(:name, "example") 119 | selected?(element) 120 | 121 | You can also pass the selector as a tuple. 122 | 123 | selected?({:name, "example"}) 124 | """ 125 | @spec selected?(Hound.Element.selector) :: boolean 126 | def selected?(element) do 127 | element = get_element(element) 128 | session_id = Hound.current_session_id 129 | make_req(:get, "session/#{session_id}/element/#{element}/selected") 130 | end 131 | 132 | 133 | @doc """ 134 | Checks if an input field is enabled. 135 | 136 | element = find_element(:name, "example") 137 | element_enabled?(element) 138 | 139 | You can also pass the selector as a tuple. 140 | 141 | element_enabled?({:name, "example"}) 142 | """ 143 | @spec element_enabled?(Hound.Element.selector) :: boolean 144 | def element_enabled?(element) do 145 | element = get_element(element) 146 | session_id = Hound.current_session_id 147 | make_req(:get, "session/#{session_id}/element/#{element}/enabled") 148 | end 149 | 150 | 151 | @doc """ 152 | Gets an element's attribute value. 153 | 154 | element = find_element(:name, "example") 155 | attribute_value(element, "data-greeting") 156 | 157 | You can also pass the selector as a tuple, for the first argument 158 | 159 | attribute_value({:name, "example"}, "data-greeting") 160 | """ 161 | @spec attribute_value(Hound.Element.selector, String.t) :: String.t | :nil 162 | def attribute_value(element, attribute_name) do 163 | element = get_element(element) 164 | session_id = Hound.current_session_id 165 | make_req(:get, "session/#{session_id}/element/#{element}/attribute/#{attribute_name}") 166 | end 167 | 168 | 169 | @doc """ 170 | Checks if an element has a given class. 171 | 172 | element = find_element(:class, "another_example") 173 | has_class?(element, "another_class") 174 | 175 | You can also pass the selector as a tuple, for the first argument 176 | 177 | has_class?({:class, "another_example"}, "another_class") 178 | """ 179 | @spec has_class?(Hound.Element.selector, String.t) :: boolean 180 | def has_class?(element, class) do 181 | class_attribute = attribute_value(element, "class") 182 | String.split(class_attribute) |> Enum.member?(class) 183 | end 184 | 185 | 186 | @doc """ 187 | Checks if two elements refer to the same DOM element. 188 | 189 | element1 = find_element(:name, "username") 190 | element2 = find_element(:id, "user_name") 191 | same_element?(element1, element2) 192 | """ 193 | @spec same_element?(Hound.Element.t, Hound.Element.t) :: boolean 194 | def same_element?(element1, element2) do 195 | session_id = Hound.current_session_id 196 | make_req(:get, "session/#{session_id}/element/#{element1}/equals/#{element2}") 197 | end 198 | 199 | 200 | @doc """ 201 | Checks if an element is currently displayed. 202 | 203 | element = find_element(:name, "example") 204 | element_displayed?(element) 205 | 206 | You can also pass the selector as a tuple. 207 | 208 | element_displayed?({:name, "example"}) 209 | 210 | Note: If you'd like to check presence of elements in the DOM use `element?/2`, 211 | `element_displayed?/1` will only consider elements that are always present in the DOM, either in visible or hidden state. 212 | """ 213 | @spec element_displayed?(Hound.Element.selector) :: :true | :false 214 | def element_displayed?(element) do 215 | element = get_element(element) 216 | session_id = Hound.current_session_id 217 | make_req(:get, "session/#{session_id}/element/#{element}/displayed") 218 | end 219 | 220 | 221 | @doc """ 222 | Gets an element's location on page. It returns the location as a tuple of the form {x, y}. 223 | 224 | element = find_element(:name, "example") 225 | element_location(element) 226 | 227 | You can also pass the selector as a tuple. 228 | 229 | element_location({:name, "example"}) 230 | """ 231 | @spec element_location(Hound.Element.selector) :: {non_neg_integer(), non_neg_integer()} 232 | def element_location(element) do 233 | element = get_element(element) 234 | session_id = Hound.current_session_id 235 | result = make_req(:get, "session/#{session_id}/element/#{element}/location") 236 | {result["x"], result["y"]} 237 | end 238 | 239 | 240 | @doc """ 241 | Gets an element's size in pixels. It returns the size as a tuple of the form {width, height}. 242 | 243 | element = find_element(:name, "example") 244 | element_location(element) 245 | 246 | You can also pass the selector as a tuple. 247 | 248 | element_location({:name, "example"}) 249 | """ 250 | @spec element_size(Hound.Element.selector) :: {non_neg_integer(), non_neg_integer()} 251 | def element_size(element) do 252 | element = get_element(element) 253 | session_id = Hound.current_session_id 254 | result = make_req(:get, "session/#{session_id}/element/#{element}/size") 255 | {result["width"], result["height"]} 256 | end 257 | 258 | 259 | @doc """ 260 | Gets an element's computed CSS property. 261 | 262 | element = find_element(:name, "example") 263 | css_property(element, "display") 264 | 265 | You can also pass the selector as a tuple, for the first argument 266 | 267 | css_property({:name, "example"}, "display") 268 | """ 269 | @spec css_property(Hound.Element.selector, String.t) :: String.t 270 | def css_property(element, property_name) do 271 | element = get_element(element) 272 | session_id = Hound.current_session_id 273 | make_req(:get, "session/#{session_id}/element/#{element}/css/#{property_name}") 274 | end 275 | 276 | 277 | @doc """ 278 | Click on an element. You can also use this to click on checkboxes and radio buttons. 279 | 280 | element = find_element(:id, "example") 281 | click(element) 282 | 283 | You can also directly pass the selector as a tuple. 284 | 285 | click({:id, "example"}) 286 | """ 287 | @spec click(Hound.Element.selector) :: :ok 288 | def click(element) do 289 | element = get_element(element) 290 | session_id = Hound.current_session_id 291 | make_req(:post, "session/#{session_id}/element/#{element}/click") 292 | end 293 | 294 | 295 | @doc """ 296 | Moves the mouse to a given position within the given element. X and Y are relatively to the element 297 | and start from top left. 298 | 299 | element = find_element(:id, "example") 300 | move_to(element, 10, 10) 301 | 302 | You can also directly pass the selector as a tuple. 303 | 304 | move_to({:id, "example"}, 10, 10) 305 | """ 306 | @spec move_to(Hound.Element.selector, integer, integer) :: :ok 307 | def move_to(element, xoffset, yoffset) do 308 | element = get_element(element) 309 | session_id = Hound.current_session_id 310 | make_req(:post, "session/#{session_id}/moveto", %{element: element.uuid, xoffset: xoffset, yoffset: yoffset}) 311 | end 312 | 313 | @doc """ 314 | Sends a submit event to any field or form element. 315 | 316 | element = find_element(:name, "username") 317 | submit_element(element) 318 | 319 | You can also directly pass the selector as a tuple. 320 | 321 | submit_element({:name, "username"}) 322 | """ 323 | @spec submit_element(Hound.Element.selector) :: :ok 324 | def submit_element(element) do 325 | element = get_element(element) 326 | session_id = Hound.current_session_id 327 | make_req(:post, "session/#{session_id}/element/#{element}/submit") 328 | end 329 | 330 | 331 | @doc false 332 | defp get_element({strategy, selector}), 333 | do: Hound.Helpers.Page.find_element(strategy, selector) 334 | defp get_element(%Hound.Element{} = elem), 335 | do: elem 336 | end 337 | -------------------------------------------------------------------------------- /lib/hound/helpers/log.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Log do 2 | @moduledoc "Functions to work with the log" 3 | 4 | import Hound.RequestUtils 5 | require Hound.NotSupportedError 6 | 7 | @doc """ 8 | Fetches console.log() from the browser as a single string of log-lines. 9 | """ 10 | def fetch_log() do 11 | fail_if_webdriver_selenium("fetch_log()") 12 | 13 | get_browser_log() 14 | |> Enum.map_join("\n", &(Map.get(&1, "message"))) 15 | end 16 | 17 | @doc """ 18 | Fetches all console.log() lines of type 'WARINING' and CRITICAL, from the browser as a single string of log-lines. 19 | """ 20 | def fetch_errors() do 21 | fail_if_webdriver_selenium("fetch_errors()") 22 | 23 | get_browser_log() 24 | |> Enum.filter(&(is_error(&1))) 25 | |> Enum.map_join("\n", &(Map.get(&1, "message"))) 26 | end 27 | 28 | defp fail_if_webdriver_selenium(function) do 29 | Hound.NotSupportedError.raise_for(%{driver: "selenium", browser: "firefox"}, function) 30 | end 31 | 32 | defp get_browser_log() do 33 | session_id = Hound.current_session_id 34 | make_req(:post, "session/#{session_id}/log", %{type: "browser"}) 35 | end 36 | 37 | defp is_error(map) do 38 | level(map, "WARNING") || level(map, "CRITICAL") 39 | end 40 | 41 | defp level(map, value) do 42 | Map.get(map, "level") == value 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/hound/helpers/mouse.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Mouse do 2 | @moduledoc "Functions to work with the mouse" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc """ 7 | Triggers a mousedown event on the current position of the mouse, which can be set through `Helpers.Element.move_to/3`. 8 | The mousedown event can get triggered with three different "buttons": 9 | 1. Primary Button = 0 which is the default (in general, the left button) 10 | 2. Auxiliary Button = 1 (in general, the middle button) 11 | 3. Secondary Button = 2 (in general, the right button) 12 | 13 | mouse_down() 14 | """ 15 | @spec mouse_down(integer) :: :ok 16 | def mouse_down(button \\ 0) do 17 | session_id = Hound.current_session_id 18 | make_req(:post, "session/#{session_id}/buttondown", %{button: button}) 19 | end 20 | 21 | 22 | @doc """ 23 | Triggers a mouseup event on the current position of the mouse, which can be set through `Helpers.Element.move_to/3`. 24 | The mouseup event can get triggered with three different "buttons": 25 | 1. Primary Button = 0 which is the default (in general, the left button) 26 | 2. Auxiliary Button = 1 (in general, the middle button) 27 | 3. Secondary Button = 2 (in general, the right button) 28 | 29 | mouse_up() 30 | """ 31 | @spec mouse_up(integer) :: :ok 32 | def mouse_up(button \\ 0) do 33 | session_id = Hound.current_session_id 34 | make_req(:post, "session/#{session_id}/buttonup", %{button: button}) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/hound/helpers/navigation.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Navigation do 2 | @moduledoc "Navigation functions" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc "Gets url of the current page." 7 | @spec current_url :: String.t 8 | def current_url do 9 | session_id = Hound.current_session_id 10 | make_req(:get, "session/#{session_id}/url") 11 | end 12 | 13 | @doc "Gets the path of the current page." 14 | def current_path do 15 | URI.parse(current_url()).path 16 | end 17 | 18 | @doc """ 19 | Navigates to a url or relative path. 20 | 21 | navigate_to("http://example.com/page1") 22 | navigate_to("/page1") 23 | """ 24 | @spec navigate_to(String.t, integer) :: nil 25 | def navigate_to(url, retries \\ 0) do 26 | final_url = generate_final_url(url) 27 | session_id = Hound.current_session_id 28 | make_req(:post, "session/#{session_id}/url", %{url: final_url}, %{}, retries) 29 | end 30 | 31 | 32 | @doc "Navigates forward in browser history." 33 | @spec navigate_forward :: :ok 34 | def navigate_forward do 35 | session_id = Hound.current_session_id 36 | make_req(:post, "session/#{session_id}/forward") 37 | end 38 | 39 | 40 | @doc "Navigates back in browser history." 41 | @spec navigate_back() :: :ok 42 | def navigate_back do 43 | session_id = Hound.current_session_id 44 | make_req(:post, "session/#{session_id}/back") 45 | end 46 | 47 | 48 | @doc "Refreshes the current page." 49 | @spec refresh_page() :: :ok 50 | def refresh_page do 51 | session_id = Hound.current_session_id 52 | make_req(:post, "session/#{session_id}/refresh") 53 | end 54 | 55 | 56 | defp generate_final_url(url) do 57 | {:ok, configs} = Hound.configs 58 | 59 | if relative_path?(url) do 60 | "#{configs[:host]}:#{configs[:port]}#{url}" 61 | else 62 | url 63 | end 64 | end 65 | 66 | defp relative_path?(url) do 67 | String.starts_with?(url, "/") 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /lib/hound/helpers/orientation.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Orientation do 2 | @moduledoc "Functions related to orientation" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc """ 7 | Gets browser's orientation. Will return either `:landscape` or `:portrait`. 8 | """ 9 | @spec orientation() :: :landscape | :portrait 10 | def orientation do 11 | session_id = Hound.current_session_id 12 | make_req(:get, "session/#{session_id}/orientation") 13 | end 14 | 15 | 16 | @doc """ 17 | Sets browser's orientation. 18 | 19 | `:landscape` or `:portrait` are valid values for the first argument. 20 | 21 | set_orientation(:landscape) 22 | set_orientation(:portrait) 23 | """ 24 | @spec set_orientation(:landscape | :portrait) :: :ok 25 | def set_orientation(orientation) do 26 | session_id = Hound.current_session_id 27 | make_req(:get, "session/#{session_id}/orientation", %{orientation: orientation}) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/hound/helpers/page.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Page do 2 | @moduledoc "Provides element finders, form fillers and page-related functions" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc "Gets the HTML source of current page." 7 | @spec page_source() :: String.t 8 | def page_source do 9 | session_id = Hound.current_session_id 10 | make_req(:get, "session/#{session_id}/source") 11 | end 12 | 13 | @doc "Gets the visible text of current page." 14 | @spec visible_page_text() :: String.t 15 | def visible_page_text do 16 | element = find_element(:css, "body") 17 | session_id = Hound.current_session_id 18 | make_req(:get, "session/#{session_id}/element/#{element}/text") 19 | end 20 | 21 | @doc "Gets the title of the current page." 22 | @spec page_title() :: String.t 23 | def page_title do 24 | session_id = Hound.current_session_id 25 | make_req(:get, "session/#{session_id}/title") 26 | end 27 | 28 | 29 | @doc """ 30 | Finds element on current page. It returns an element that can be used with other element functions. 31 | 32 | * The first argument is the strategy. 33 | * The second argument is the selector. 34 | 35 | Valid selector strategies are `:css`, `:class`, `:id`, `:name`, `:tag`, `:xpath`, `:link_text` and `:partial_link_text` 36 | `raises` if the element is not found or an error happens. 37 | 38 | 39 | find_element(:name, "username") 40 | find_element(:class, "example") 41 | find_element(:id, "example") 42 | find_element(:css, ".example") 43 | find_element(:tag, "footer") 44 | find_element(:link_text, "Home") 45 | """ 46 | @spec find_element(Hound.Element.strategy, String.t, non_neg_integer) :: Hound.Element.t 47 | def find_element(strategy, selector, retries \\ 5) do 48 | case search_element(strategy, selector, retries) do 49 | {:ok, element} -> element 50 | {:error, :no_such_element} -> 51 | raise Hound.NoSuchElementError, strategy: strategy, selector: selector 52 | {:error, err} -> 53 | raise Hound.Error, "could not get element {#{inspect(strategy)}, #{inspect(selector)}}: #{inspect(err)}" 54 | end 55 | end 56 | 57 | @doc """ 58 | Same as `find_element/3`, but returns the a tuple with `{:error, error}` instead of raising 59 | """ 60 | @spec search_element(Hound.Element.strategy, String.t, non_neg_integer) :: {:ok, Hound.Element.t} | {:error, any} 61 | def search_element(strategy, selector, retries \\ 5) do 62 | session_id = Hound.current_session_id 63 | params = %{using: Hound.InternalHelpers.selector_strategy(strategy), value: selector} 64 | 65 | make_req(:post, "session/#{session_id}/element", params, %{safe: true}, retries*2) 66 | |> process_element_response 67 | end 68 | 69 | 70 | @doc """ 71 | Finds elements on current page. Returns an array of elements that can be used with other element functions. 72 | 73 | * The first argument is the strategy. 74 | * The second argument is the selector. 75 | 76 | Valid selector strategies are `:css`, `:class`, `:id`, `:name`, `:tag`, `:xpath`, `:link_text` and `:partial_link_text` 77 | 78 | find_all_elements(:name, "username") 79 | find_all_elements(:class, "example") 80 | find_all_elements(:id, "example") 81 | find_all_elements(:css, ".example") 82 | find_all_elements(:tag, "footer") 83 | find_all_elements(:link_text, "Home") 84 | """ 85 | @spec find_all_elements(atom, String.t, non_neg_integer) :: list 86 | def find_all_elements(strategy, selector, retries \\ 5) do 87 | session_id = Hound.current_session_id 88 | params = %{using: Hound.InternalHelpers.selector_strategy(strategy), value: selector} 89 | 90 | case make_req(:post, "session/#{session_id}/elements", params, %{}, retries*2) do 91 | {:error, value} -> 92 | {:error, value} 93 | elements -> 94 | Enum.map(elements, &Hound.Element.from_response/1) 95 | end 96 | end 97 | 98 | 99 | @doc """ 100 | Finds element within a specific element. Returns an element to use with element helper functions. 101 | 102 | * The first argument is the element within which you want to search. 103 | * The second argument is the strategy. 104 | * The third argument is the selector. 105 | 106 | Valid selector strategies are `:css`, `:class`, `:id`, `:name`, `:tag`, `:xpath`, `:link_text` and `:partial_link_text` 107 | 108 | # First get an element to search within 109 | parent_element = find_element(:class, "container") 110 | 111 | find_within_element(parent_element, :name, "username") 112 | find_within_element(parent_element, :class, "example") 113 | find_within_element(parent_element, :id, "example") 114 | find_within_element(parent_element, :css, ".example") 115 | find_within_element(parent_element, :tag, "footer") 116 | find_within_element(parent_element, :link_text, "Home") 117 | """ 118 | @spec find_within_element(Hound.Element.t, Hound.Element.strategy, String.t, non_neg_integer) :: Hound.Element.t 119 | def find_within_element(element, strategy, selector, retries \\ 5) do 120 | case search_within_element(element, strategy, selector, retries) do 121 | {:error, :no_such_element} -> 122 | raise Hound.NoSuchElementError, strategy: strategy, selector: selector, parent: element 123 | {:error, err} -> 124 | raise Hound.Error, "could not get element {#{inspect(strategy)}, #{inspect(selector)}} in #{element}: #{inspect(err)}" 125 | {:ok, element} -> element 126 | end 127 | end 128 | 129 | @doc """ 130 | Same as `find_within_element/4`, but returns a `{:error, err}` tuple instead of raising 131 | """ 132 | @spec search_within_element(Hound.Element.t, Hound.Element.strategy, String.t, non_neg_integer) :: {:ok, Hound.Element.t} | {:error, any} 133 | def search_within_element(element, strategy, selector, retries \\ 5) do 134 | session_id = Hound.current_session_id 135 | params = %{using: Hound.InternalHelpers.selector_strategy(strategy), value: selector} 136 | 137 | make_req(:post, "session/#{session_id}/element/#{element}/element", params, %{safe: true}, retries*2) 138 | |> process_element_response 139 | end 140 | 141 | 142 | @doc """ 143 | Finds elements within a specific element. Returns an array of elements that can be used with other element functions. 144 | 145 | * The first argument is the element within which you want to search. 146 | * The second argument is the strategy. 147 | * The third argument is the selector. 148 | 149 | Valid selector strategies are `:css`, `:class`, `:id`, `:name`, `:tag`, `:xpath`, `:link_text` and `:partial_link_text` 150 | 151 | # First get an element to search within 152 | parent_element = find_element(:class, "container") 153 | 154 | find_all_within_element(parent_element, :name, "username") 155 | find_all_within_element(parent_element, :class, "example") 156 | find_all_within_element(parent_element, :id, "example") 157 | find_all_within_element(parent_element, :css, ".example") 158 | find_all_within_element(parent_element, :tag, "footer") 159 | find_all_within_element(parent_element, :link_text, "Home") 160 | """ 161 | @spec find_all_within_element(Hound.Element.t, atom, String.t, non_neg_integer) :: list 162 | def find_all_within_element(element, strategy, selector, retries \\ 5) do 163 | session_id = Hound.current_session_id 164 | params = %{using: Hound.InternalHelpers.selector_strategy(strategy), value: selector} 165 | 166 | case make_req(:post, "session/#{session_id}/element/#{element}/elements", params, %{}, retries*2) do 167 | {:error, value} -> 168 | {:error, value} 169 | elements -> 170 | Enum.map(elements, &Hound.Element.from_response/1) 171 | end 172 | end 173 | 174 | 175 | @doc "Gets element on page that is currently in focus." 176 | @spec element_in_focus() :: map 177 | def element_in_focus do 178 | session_id = Hound.current_session_id 179 | make_req(:post, "session/#{session_id}/element/active") 180 | |> Hound.Element.from_response 181 | end 182 | 183 | 184 | @doc """ 185 | Holds on to the specified modifier keys when the block is executing. 186 | 187 | # Simulates Ctrl + e 188 | with_keys :control do 189 | send_text "e" 190 | end 191 | 192 | # Simulates Ctrl + Shift + e 193 | with_keys [:control, :shift] do 194 | send_text "e" 195 | end 196 | 197 | The following are the modifier keys: 198 | 199 | * :alt - alt key 200 | * :shift - shift key 201 | * :command - command key (or meta key) 202 | * :control - control key 203 | * :escape - escape key 204 | """ 205 | defmacro with_keys(keys, blocks) do 206 | do_block = Keyword.get(blocks, :do, nil) 207 | quote do 208 | send_keys(unquote(keys)) 209 | unquote(do_block) 210 | send_keys(:null) 211 | end 212 | end 213 | 214 | 215 | @doc """ 216 | Send sequence of key strokes to active element. 217 | The keys are accepted as a list of atoms. 218 | 219 | send_keys :backspace 220 | send_keys :tab 221 | 222 | If you send the modifier keys shift, control, alt and command, 223 | they are held on and not released until you send the `:null` key. 224 | 225 | To perform other actions while holding on to modifier keys, use the `with_keys` macro. 226 | 227 | The following are the atoms representing the keys: 228 | 229 | * :alt - alt key 230 | * :shift - shift key 231 | * :command - command key (or meta key) 232 | * :control - control key 233 | * :escape - escape key 234 | * :backspace - backspace key 235 | * :tab - tab key 236 | * :clear - clear 237 | * :return - return key 238 | * :enter - enter key 239 | * :cancel - cancel key 240 | * :help - help key 241 | * :pause - pause key 242 | * :num0 - numpad 0 243 | * :num1 - numpad 1 244 | * :num2 - numpad 2 245 | * :num3 - numpad 3 246 | * :num4 - numpad 4 247 | * :num5 - numpad 5 248 | * :num6 - numpad 6 249 | * :num7 - numpad 7 250 | * :num8 - numpad 8 251 | * :num9 - numpad 9 252 | * :add - add key 253 | * :subtract - subtract key 254 | * :multiply - multiply key 255 | * :divide - divide key 256 | * :seperator - seperator key 257 | """ 258 | @spec send_keys(list | atom) :: :ok 259 | def send_keys(keys) when is_atom(keys) or is_list(keys) do 260 | keys = List.wrap(keys) 261 | session_id = Hound.current_session_id 262 | make_req(:post, 263 | "session/#{session_id}/keys", 264 | Hound.InternalHelpers.key_codes_json(keys), 265 | %{json_encode: false}) 266 | end 267 | 268 | 269 | @doc """ 270 | Send character keys to active element. 271 | 272 | send_text "test" 273 | send_text "whatever happens" 274 | 275 | To send key strokes like tab, enter, etc, take a look at `send_keys`. 276 | """ 277 | @spec send_text(String.t) :: :ok 278 | def send_text(keys) do 279 | session_id = Hound.current_session_id 280 | %Hound.Element{uuid: uuid} = element_in_focus() 281 | make_req(:post, "session/#{session_id}/element/#{uuid}/value", %{value: [keys]}) 282 | end 283 | 284 | defp process_element_response(%{"ELEMENT" => element_id}), 285 | do: {:ok, %Hound.Element{uuid: element_id}} 286 | defp process_element_response(%{"element-6066-11e4-a52e-4f735466cecf" => element_id}), 287 | do: {:ok, %Hound.Element{uuid: element_id}} 288 | defp process_element_response({:error, _err} = error), 289 | do: error 290 | defp process_element_response(unknown_error), 291 | do: {:error, unknown_error} 292 | end 293 | -------------------------------------------------------------------------------- /lib/hound/helpers/save_page.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.SavePage do 2 | @moduledoc "Provides helper function to save the current page" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc """ 7 | Save the dom of the current page. The page is saved in the current working directory. 8 | It returns the path of the html file, to which the dom has been saved. 9 | 10 | For Elixir mix projects, the saved screenshot can be found in the root of the project directory. 11 | 12 | save_page() 13 | 14 | You can also pass a file path to which the screenshot must be saved to. 15 | 16 | # Pass a full file path 17 | save_page("/media/pages/test.html") 18 | 19 | # Or you can also pass a path relative to the current directory. save_page("page.html") 20 | """ 21 | @spec save_page(String.t) :: String.t 22 | def save_page(path \\ default_path()) do 23 | session_id = Hound.current_session_id 24 | page_data = make_req(:get, "session/#{session_id}/source") 25 | 26 | :ok = File.write path, page_data 27 | path 28 | end 29 | 30 | defp default_path do 31 | Hound.Utils.temp_file_path("page", "html") 32 | end 33 | 34 | end 35 | -------------------------------------------------------------------------------- /lib/hound/helpers/screenshot.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Screenshot do 2 | @moduledoc "Provides helper function to take screenshots" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc """ 7 | Takes screenshot of the current page. The screenshot is saved in the current working directory. 8 | It returns the path of the png file, to which the screenshot has been saved. 9 | 10 | For Elixir mix projects, the saved screenshot can be found in the root of the project directory. 11 | 12 | take_screenshot() 13 | 14 | You can also pass a file path to which the screenshot must be saved to. 15 | 16 | # Pass a full file path 17 | take_screenshot("/media/screenshots/test.png") 18 | 19 | # Or you can also pass a path relative to the current directory. take_screenshot("screenshot-test.png") 20 | """ 21 | @spec take_screenshot(String.t) :: String.t 22 | def take_screenshot(path \\ default_path()) do 23 | session_id = Hound.current_session_id 24 | base64_png_data = make_req(:get, "session/#{session_id}/screenshot") 25 | binary_image_data = :base64.decode(base64_png_data) 26 | 27 | :ok = File.write path, binary_image_data 28 | path 29 | end 30 | 31 | defp default_path do 32 | Hound.Utils.temp_file_path("screenshot", "png") 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /lib/hound/helpers/script_execution.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.ScriptExecution do 2 | @moduledoc "Functions to execute javascript" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc """ 7 | Execute javascript synchronously. 8 | 9 | * The first argument is the script to execute. 10 | * The second argument is a list of arguments that is passed. 11 | These arguments are accessible in the script via `arguments`. 12 | 13 | execute_script("return(arguments[0] + arguments[1]);", [1, 2]) 14 | 15 | execute_script("doSomething(); return(arguments[0] + arguments[1]);") 16 | """ 17 | @spec execute_script(String.t, list) :: any 18 | def execute_script(script_function, function_args \\ []) do 19 | session_id = Hound.current_session_id 20 | make_req(:post, 21 | "session/#{session_id}/execute", 22 | %{script: script_function, args: function_args} 23 | ) 24 | end 25 | 26 | 27 | @doc """ 28 | Execute javascript asynchronously. 29 | 30 | * The first argument is the script to execute. 31 | * The second argument is a list of arguments that is passed. 32 | These arguments are accessible in the script via `arguments`. 33 | 34 | Webdriver passes a callback function as the last argument to the script. 35 | When your script has completed execution, it has to call the last argument, 36 | which is a callback function, to indicate that the execute is complete. 37 | 38 | # Once we perform whatever we want, 39 | # we call the callback function with the arguments that must be returned. 40 | execute_script_async("doSomething(); arguments[arguments.length-1]('hello')", []) 41 | 42 | # We have no arguments to pass, so we'll skip the second argument. 43 | execute_script_async("console.log('hello'); doSomething(); arguments[arguments.length-1]()") 44 | 45 | Unless you call the callback function, the function is not assumed to be completed. 46 | It will error out. 47 | """ 48 | @spec execute_script_async(String.t, list) :: any 49 | def execute_script_async(script_function, function_args \\ []) do 50 | session_id = Hound.current_session_id 51 | make_req(:post, 52 | "session/#{session_id}/execute_async", 53 | %{script: script_function, args: function_args} 54 | ) 55 | end 56 | 57 | @doc """ 58 | Execute a phantomjs script to configure callbacks. 59 | This will only work with phantomjs driver. 60 | 61 | * The first argument is the script to execute. 62 | * The second argument is a list of arguments that is passed. 63 | These arguments are accessible in the script via `arguments`. 64 | 65 | execute_phantom_script("return(arguments[0] + arguments[1]);", [1, 2]) 66 | 67 | execute_phantom_script("doSomething(); return(arguments[0] + arguments[1]);") 68 | 69 | * NOTE: "this" in the context of the script function refers to the phantomjs 70 | result of require('webpage').create(). 71 | 72 | To use it, capture it in a variable at the beginning of the script. Example: 73 | 74 | page = this; 75 | 76 | page.onResourceRequested = function(requestData, request) { 77 | // Do something with the request 78 | }; 79 | 80 | """ 81 | @spec execute_phantom_script(String.t, list) :: any 82 | def execute_phantom_script(script_function, function_args \\ []) do 83 | session_id = Hound.current_session_id 84 | make_req(:post, 85 | "/session/#{session_id}/phantom/execute", 86 | %{script: script_function, args: function_args} 87 | ) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/hound/helpers/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Session do 2 | @moduledoc "Session helpers" 3 | 4 | @doc """ 5 | Switches to another session. 6 | 7 | When you need more than one browser session, use this function switch to another session. 8 | If the session doesn't exist it a new one will be created for you. 9 | All further commands will then run in the session you switched to. 10 | 11 | # Pass any name to the session to refer to it later. 12 | change_session_to("random-session") 13 | 14 | The name can be an atom or a string. The default session created is called `:default`. 15 | """ 16 | def change_session_to(session_name, opts \\ []) do 17 | Hound.SessionServer.change_current_session_for_pid(self(), session_name, opts) 18 | end 19 | 20 | 21 | @doc """ 22 | When running multiple browser sessions, calling this function will switch to the default browser session. 23 | 24 | change_to_default_session 25 | 26 | # is the same as calling 27 | change_session_to(:default) 28 | """ 29 | def change_to_default_session do 30 | change_session_to(:default) 31 | end 32 | 33 | 34 | @doc """ 35 | Execute commands in a separate browser session. 36 | 37 | in_browser_session "another_user", fn -> 38 | navigate_to "http://example.com" 39 | click({:id, "announcement"}) 40 | end 41 | """ 42 | def in_browser_session(session_name, func) do 43 | previous_session_name = current_session_name() 44 | change_session_to(session_name) 45 | result = apply(func, []) 46 | change_session_to(previous_session_name) 47 | result 48 | end 49 | 50 | 51 | @doc """ 52 | Starts a Hound session. 53 | 54 | Use this in your test case's setup block to start a Hound 55 | session for each test case. The session will be terminated 56 | when the caller process exits or when `end_session/0` is 57 | explicitly called. 58 | 59 | defmodule HoundTest do 60 | use ExUnit.Case 61 | use Hound.Helpers 62 | 63 | setup do 64 | Hound.start_session 65 | :ok 66 | end 67 | 68 | test "the truth", meta do 69 | navigate_to("http://example.com/guestbook.html") 70 | 71 | find_element(:name, "message") 72 | |> fill_field("Happy Birthday ~!") 73 | |> submit_element() 74 | 75 | assert page_title() == "Thank you" 76 | end 77 | 78 | end 79 | 80 | ## Options 81 | 82 | The following options can be passed to `start_session`: 83 | 84 | * `:browser` - The browser to be used (`"chrome"` | `"chrome_headless"` | `"phantomjs"` | `"firefox"`) 85 | * `:user_agent` - The user agent string that will be used for the requests. 86 | The following atoms can also be passed 87 | * `:firefox_desktop` (aliased to `:firefox`) 88 | * `:chrome_desktop` (aliased to `:chrome`) 89 | * `:phantomjs` 90 | * `:chrome_android_sp` (aliased to `:android`) 91 | * `:safari_iphone` (aliased to `:iphone`) 92 | * `:metadata` - The metadata to be included in the requests. 93 | See `Hound.Metadata` for more information 94 | * `:driver` - The additional capabilities to be passed directly to the webdriver. 95 | """ 96 | def start_session(opts \\ []) do 97 | Hound.SessionServer.session_for_pid(self(), opts) 98 | end 99 | 100 | 101 | @doc """ 102 | Ends a Hound session that is associated with a pid. 103 | 104 | If you have multiple sessions, all of those sessions are killed. 105 | """ 106 | def end_session(pid \\ self()) do 107 | Hound.SessionServer.destroy_sessions_for_pid(pid) 108 | end 109 | 110 | 111 | @doc false 112 | def current_session_id do 113 | Hound.SessionServer.current_session_id(self()) || 114 | raise "could not find a session for process #{inspect self()}" 115 | end 116 | 117 | 118 | @doc false 119 | def current_session_name do 120 | Hound.SessionServer.current_session_name(self()) || 121 | raise "could not find a session for process #{inspect self()}" 122 | 123 | 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/hound/helpers/window.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Helpers.Window do 2 | @moduledoc "Window size and other window-related functions" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc "Get all window handles available to the session" 7 | @spec current_window_handle() :: String.t 8 | def current_window_handle do 9 | session_id = Hound.current_session_id 10 | make_req(:get, "session/#{session_id}/window_handle") 11 | end 12 | 13 | @doc "Get list of window handles available to the session" 14 | @spec window_handles() :: list 15 | def window_handles do 16 | session_id = Hound.current_session_id 17 | make_req(:get, "session/#{session_id}/window_handles") 18 | end 19 | 20 | @doc "Change size of the window" 21 | @spec set_window_size(String.t, integer, integer) :: :ok 22 | def set_window_size(window_handle, width, height) do 23 | session_id = Hound.current_session_id 24 | make_req(:post, "session/#{session_id}/window/#{window_handle}/size", %{width: width, height: height}) 25 | end 26 | 27 | @doc "Get size of the window" 28 | @spec window_size(String.t) :: tuple 29 | def window_size(window_handle) do 30 | session_id = Hound.current_session_id 31 | size = make_req(:get, "session/#{session_id}/window/#{window_handle}/size") 32 | {Map.get(size, "width"), Map.get(size, "height")} 33 | end 34 | 35 | @doc "Maximize the window" 36 | @spec maximize_window(String.t) :: :ok 37 | def maximize_window(window_handle) do 38 | session_id = Hound.current_session_id 39 | make_req(:post, "session/#{session_id}/window/#{window_handle}/maximize") 40 | end 41 | 42 | @doc "Focus the window" 43 | @spec focus_window(String.t) :: nil 44 | def focus_window(window_handle) do 45 | session_id = Hound.current_session_id 46 | make_req(:post, "session/#{session_id}/window", %{handle: window_handle, name: window_handle}) 47 | end 48 | 49 | @doc "Close the current window" 50 | @spec close_current_window :: nil 51 | def close_current_window do 52 | session_id = Hound.current_session_id 53 | make_req(:delete, "session/#{session_id}/window") 54 | end 55 | 56 | @doc """ 57 | Set the focus to a specific frame, such as an iframe 58 | 59 | ## Example 60 | 61 | iex> iframe = find_element(:id, "id-of-some-iframe") 62 | iex> focus_frame(iframe) 63 | nil 64 | """ 65 | @spec focus_frame(any) :: :ok 66 | def focus_frame(frame_id) do 67 | session_id = Hound.current_session_id 68 | make_req(:post, "session/#{session_id}/frame", %{id: frame_id}) 69 | end 70 | 71 | 72 | @doc "Change focus to parent frame" 73 | @spec focus_parent_frame() :: :ok 74 | def focus_parent_frame() do 75 | session_id = Hound.current_session_id 76 | make_req(:post, "session/#{session_id}/frame/parent") 77 | end 78 | 79 | 80 | # TODO 81 | # @doc "Get window position" 82 | # def window_position(window_handle) do 83 | # end 84 | 85 | # @doc "Set window position" 86 | # def set_window_position(window_handle, position) do 87 | # end 88 | end 89 | -------------------------------------------------------------------------------- /lib/hound/internal_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.InternalHelpers do 2 | @moduledoc false 3 | 4 | @unsupported_features [ 5 | phantomjs: [ 6 | "dialog_text", 7 | "input_into_prompt", 8 | "accept_dialog", 9 | "dismiss_dialog", 10 | "delete_cookies", 11 | "focus_parent_frame" 12 | ], 13 | chrome_driver: [], 14 | selenium: [] 15 | ] 16 | 17 | 18 | def selector_strategy(:class), do: "class name" 19 | def selector_strategy(:css), do: "css selector" 20 | def selector_strategy(:id), do: "id" 21 | def selector_strategy(:name), do: "name" 22 | def selector_strategy(:tag), do: "tag name" 23 | def selector_strategy(:xpath), do: "xpath" 24 | def selector_strategy(:link_text), do: "link text" 25 | def selector_strategy(:partial_link_text), do: "partial link text" 26 | 27 | def key_code(:null), do: "\\uE000" 28 | def key_code(:cancel), do: "\\uE001" 29 | def key_code(:help), do: "\\uE002" 30 | def key_code(:backspace), do: "\\uE003" 31 | def key_code(:tab), do: "\\uE004" 32 | def key_code(:clear), do: "\\uE005" 33 | def key_code(:return), do: "\\uE006" 34 | def key_code(:enter), do: "\\uE007" 35 | def key_code(:shift), do: "\\uE008" 36 | def key_code(:control), do: "\\uE009" 37 | def key_code(:alt), do: "\\uE00A" 38 | def key_code(:pause), do: "\\uE00B" 39 | def key_code(:escape), do: "\\uE00C" 40 | 41 | def key_code(:space), do: "\\eE00D" 42 | def key_code(:pageup), do: "\\uE00E" 43 | def key_code(:pagedown), do: "\\uE00F" 44 | def key_code(:end), do: "\\uE010" 45 | def key_code(:home), do: "\\uE011" 46 | def key_code(:left_arrow), do: "\\uE012" 47 | def key_code(:up_arrow), do: "\\uE013" 48 | def key_code(:right_arrow), do: "\\uE014" 49 | def key_code(:down_arrow), do: "\\uE015" 50 | def key_code(:insert), do: "\\uE016" 51 | def key_code(:delete), do: "\\uE017" 52 | def key_code(:semicolon), do: "\\uE018" 53 | def key_code(:equals), do: "\\uE019" 54 | 55 | def key_code(:num0), do: "\\uE01A" 56 | def key_code(:num1), do: "\\uE01B" 57 | def key_code(:num2), do: "\\uE01C" 58 | def key_code(:num3), do: "\\uE01D" 59 | def key_code(:num4), do: "\\uE01E" 60 | def key_code(:num5), do: "\\uE01F" 61 | def key_code(:num6), do: "\\uE020" 62 | def key_code(:num7), do: "\\uE021" 63 | def key_code(:num8), do: "\\uE022" 64 | def key_code(:num9), do: "\\uE023" 65 | 66 | def key_code(:multiply), do: "\\uE024" 67 | def key_code(:add), do: "\\uE025" 68 | def key_code(:seperator), do: "\\uE026" 69 | def key_code(:subtract), do: "\\uE027" 70 | def key_code(:decimal), do: "\\uE028" 71 | def key_code(:divide), do: "\\uE029" 72 | 73 | def key_code(:command), do: "\\uE03D" 74 | 75 | def key_codes_json(keys) do 76 | unicode_string = Enum.map_join(keys, ",", fn(key)-> "\"#{key_code(key)}\"" end) 77 | "{\"value\": [#{unicode_string}]}" 78 | end 79 | 80 | 81 | def driver_supports?(feature) do 82 | {:ok, driver_info} = Hound.driver_info 83 | driver = String.to_atom(driver_info[:driver]) 84 | 85 | not Enum.member?(@unsupported_features[driver], feature) 86 | end 87 | 88 | end 89 | -------------------------------------------------------------------------------- /lib/hound/matchers.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Matchers do 2 | @moduledoc "Text and element matchers" 3 | 4 | import Hound.Helpers.Page 5 | import Hound.Helpers.Element 6 | 7 | @doc """ 8 | Returns true if text is found on the page. 9 | 10 | visible_in_page?(~r/Paragraph/) 11 | """ 12 | @spec visible_in_page?(Regex.t) :: boolean 13 | def visible_in_page?(pattern) do 14 | text = inner_text({:tag, "body"}) 15 | Regex.match?(pattern, text) 16 | end 17 | 18 | 19 | @doc """ 20 | Returns true if text is found on the page inside an element. 21 | 22 | visible_in_element?({:class, "block"}, ~r/Paragraph/) 23 | visible_in_element?({:id, "foo"}, ~r/paragraph/iu) 24 | 25 | If the element matching the selector itself is a hidden element, 26 | then the match will return true even if the text is not hidden. 27 | """ 28 | @spec visible_in_element?(Hound.Element.selector, Regex.t) :: boolean 29 | def visible_in_element?(selector, pattern) do 30 | text = inner_text(selector) 31 | Regex.match?(pattern, text) 32 | end 33 | 34 | 35 | @doc """ 36 | Returns true if an element is present. 37 | 38 | element?(:class, "block") 39 | element?(:id, "foo") 40 | """ 41 | @spec element?(Hound.Element.strategy, String.t) :: boolean 42 | def element?(strategy, selector) do 43 | find_all_elements(strategy, selector) != [] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/hound/metadata.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Metadata do 2 | @moduledoc """ 3 | Metadata allows to pass and extract custom data through. 4 | This can be useful if you need to identify sessions. 5 | 6 | The keys and values must be serializable using `:erlang.term_to_binary/1`. 7 | 8 | ## Examples 9 | 10 | You can start a session using metadata by doing the following: 11 | 12 | Hound.start_session(metadata: %{pid: self()}) 13 | 14 | 15 | If you need to retrieve the metadata, you simply need to use 16 | `Hound.Metadata.extract/1` on the user agent string, so supposing you are using plug, 17 | 18 | 19 | user_agent = conn |> get_req_header("user-agent") |> List.first 20 | metadata = Hound.Metadata.extract(user_agent) 21 | assert %{pid: pid} = metadata 22 | # you can use your pid here 23 | 24 | """ 25 | 26 | @metadata_prefix "BeamMetadata" 27 | @extract_regexp ~r{#{@metadata_prefix} \((.*?)\)} 28 | 29 | @doc """ 30 | Appends the metdata to the user_agent string. 31 | """ 32 | @spec append(String.t, nil | map | String.t) :: String.t 33 | def append(user_agent, nil), do: user_agent 34 | def append(user_agent, metadata) when is_map(metadata) or is_list(metadata) do 35 | append(user_agent, format(metadata)) 36 | end 37 | def append(user_agent, metadata) when is_binary(metadata) do 38 | "#{user_agent}/#{metadata}" 39 | end 40 | 41 | @doc """ 42 | Formats a string to a valid UserAgent string to be passed to be 43 | appended to the browser user agent. 44 | """ 45 | @spec format(map | Keyword.t) :: String.t 46 | def format(metadata) do 47 | encoded = {:v1, metadata} |> :erlang.term_to_binary |> Base.url_encode64 48 | "#{@metadata_prefix} (#{encoded})" 49 | end 50 | 51 | @doc """ 52 | Extracts and parses the metadata contained in a user agent string. 53 | If the user agent does not contain any metadata, an empty map is returned. 54 | """ 55 | @spec parse(String.t) :: %{String.t => String.t} 56 | def extract(str) do 57 | ua_last_part = str |> String.split("/") |> List.last 58 | case Regex.run(@extract_regexp, ua_last_part) do 59 | [_, metadata] -> parse(metadata) 60 | _ -> %{} 61 | end 62 | end 63 | 64 | defp parse(encoded_metadata) do 65 | encoded_metadata 66 | |> Base.url_decode64! 67 | |> :erlang.binary_to_term 68 | |> case do 69 | {:v1, metadata} -> metadata 70 | _ -> raise Hound.InvalidMetadataError, value: encoded_metadata 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/hound/request_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.RequestUtils do 2 | @moduledoc false 3 | 4 | def make_req(type, path, params \\ %{}, options \\ %{}, retries \\ retries()) 5 | def make_req(type, path, params, options, 0) do 6 | send_req(type, path, params, options) 7 | end 8 | def make_req(type, path, params, options, retries) do 9 | try do 10 | case send_req(type, path, params, options) do 11 | {:error, _} -> make_retry(type, path, params, options, retries) 12 | result -> result 13 | end 14 | rescue 15 | _ -> make_retry(type, path, params, options, retries) 16 | catch 17 | _ -> make_retry(type, path, params, options, retries) 18 | end 19 | end 20 | 21 | defp make_retry(type, path, params, options, retries) do 22 | :timer.sleep(Application.get_env(:hound, :retry_time, 250)) 23 | make_req(type, path, params, options, retries - 1) 24 | end 25 | 26 | defp send_req(type, path, params, options) do 27 | url = get_url(path) 28 | has_body = params != %{} && type == :post 29 | {headers, body} = cond do 30 | has_body && options[:json_encode] != false -> 31 | {[{"Content-Type", "text/json"}], Jason.encode!(params)} 32 | has_body -> 33 | {[], params} 34 | true -> 35 | {[], ""} 36 | end 37 | 38 | :hackney.request(type, url, headers, body, [:with_body | http_options()]) 39 | |> handle_response({url, path, type}, options) 40 | end 41 | 42 | defp handle_response({:ok, code, headers, body}, {url, path, type}, options) do 43 | case Hound.ResponseParser.parse(response_parser(), path, code, headers, body) do 44 | :error -> 45 | raise """ 46 | Webdriver call status code #{code} for #{type} request #{url}. 47 | Check if webdriver server is running. Make sure it supports the feature being requested. 48 | """ 49 | {:error, err} = value -> 50 | if options[:safe], 51 | do: value, 52 | else: raise err 53 | response -> response 54 | end 55 | end 56 | 57 | defp handle_response({:error, reason}, _, _) do 58 | {:error, reason} 59 | end 60 | 61 | defp response_parser do 62 | {:ok, driver_info} = Hound.driver_info() 63 | 64 | case {driver_info.driver, driver_info.browser} do 65 | {"selenium", "chrome" <> _headless} -> 66 | Hound.ResponseParsers.ChromeDriver 67 | 68 | {"selenium", _} -> 69 | Hound.ResponseParsers.Selenium 70 | 71 | {"chrome_driver", _} -> 72 | Hound.ResponseParsers.ChromeDriver 73 | 74 | {"phantomjs", _} -> 75 | Hound.ResponseParsers.PhantomJs 76 | 77 | other_driver -> 78 | raise "No response parser found for #{other_driver}" 79 | end 80 | end 81 | 82 | defp get_url(path) do 83 | {:ok, driver_info} = Hound.driver_info 84 | 85 | host = driver_info[:host] 86 | port = driver_info[:port] 87 | path_prefix = driver_info[:path_prefix] 88 | 89 | "#{host}:#{port}/#{path_prefix}#{path}" 90 | end 91 | 92 | defp http_options() do 93 | Application.get_env(:hound, :http, []) 94 | end 95 | 96 | defp retries() do 97 | Application.get_env(:hound, :retries, 0) 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/hound/response_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.ResponseParser do 2 | @moduledoc """ 3 | Defines a behaviour for parsing driver responses 4 | and provides a default implementation of the behaviour 5 | """ 6 | 7 | require Logger 8 | 9 | @callback handle_response(any, integer, String.t) :: any 10 | @callback handle_error(map) :: {:error, any} 11 | 12 | defmacro __using__(_) do 13 | quote do 14 | @behaviour Hound.ResponseParser 15 | 16 | @before_compile unquote(__MODULE__) 17 | 18 | def handle_response(path, code, body) do 19 | Hound.ResponseParser.handle_response(__MODULE__, path, code, body) 20 | end 21 | 22 | defdelegate warning?(message), to: Hound.ResponseParser 23 | 24 | defoverridable [handle_response: 3, warning?: 1] 25 | end 26 | end 27 | 28 | def parse(parser, path, code = 204, _headers, raw_content = "") do 29 | # Don't try to parse the json because there is none. 30 | parser.handle_response(path, code, raw_content) 31 | end 32 | def parse(parser, path, code, _headers, raw_content) do 33 | case Hound.ResponseParser.decode_content(raw_content) do 34 | {:ok, body} -> parser.handle_response(path, code, body) 35 | _ -> :error 36 | end 37 | end 38 | 39 | @doc """ 40 | Default implementation to handle drivers responses. 41 | """ 42 | def handle_response(mod, path, code, body) 43 | def handle_response(_mod, "session", code, %{"sessionId" => session_id}) when code < 300 do 44 | {:ok, session_id} 45 | end 46 | def handle_response(mod, _path, _code, %{"value" => %{"message" => message} = value}) do 47 | if mod.warning?(message) do 48 | Logger.warn(message) 49 | message 50 | else 51 | mod.handle_error(value) 52 | end 53 | end 54 | def handle_response(_mod, _path, _code, %{"status" => 0, "value" => value}), do: value 55 | def handle_response(_mod, _path, code, _body) when code < 400, do: :ok 56 | def handle_response(_mod, _path, _code, _body), do: :error 57 | 58 | @doc """ 59 | Default implementation to check if the message is a warning 60 | """ 61 | def warning?(message) do 62 | Regex.match?(~r/#{Regex.escape("not clickable")}/, message) 63 | end 64 | 65 | @doc """ 66 | Decodes a response body 67 | """ 68 | def decode_content([]), do: Map.new 69 | def decode_content(content), do: Jason.decode(content) 70 | 71 | defmacro __before_compile__(_env) do 72 | quote do 73 | @doc """ 74 | Fallback case if we did not match the message in the using module 75 | """ 76 | def handle_error(response) do 77 | case response do 78 | %{"message" => message} -> {:error, message} 79 | _ -> {:error, response} 80 | end 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/hound/response_parsers/chrome_driver.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.ResponseParsers.ChromeDriver do 2 | @moduledoc false 3 | 4 | use Hound.ResponseParser 5 | 6 | def handle_error(%{"message" => "no such element" <> _rest}) do 7 | {:error, :no_such_element} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hound/response_parsers/phantom_js.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.ResponseParsers.PhantomJs do 2 | @moduledoc false 3 | 4 | use Hound.ResponseParser 5 | 6 | def handle_error(%{"message" => message} = value) do 7 | case Jason.decode(message) do 8 | {:ok, decoded_error} -> decoded_error 9 | _ -> value 10 | end |> do_handle_error 11 | end 12 | 13 | defp do_handle_error(%{"errorMessage" => "Unable to find element" <> _rest}), 14 | do: {:error, :no_such_element} 15 | defp do_handle_error(%{"errorMessage" => msg}), 16 | do: {:error, msg} 17 | defp do_handle_error(err), 18 | do: {:error, err} 19 | end 20 | -------------------------------------------------------------------------------- /lib/hound/response_parsers/selenium.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.ResponseParsers.Selenium do 2 | @moduledoc false 3 | 4 | use Hound.ResponseParser 5 | 6 | def handle_error(%{"class" => "org.openqa.selenium.NoSuchElementException"}) do 7 | {:error, :no_such_element} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hound/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Session do 2 | @moduledoc "Low-level session functions internally used by Hound, to work with drivers. See Hound.Helpers.Session for session helpers" 3 | 4 | import Hound.RequestUtils 5 | 6 | @doc "Get server's current status" 7 | @spec server_status() :: map 8 | def server_status() do 9 | make_req(:get, "status") 10 | end 11 | 12 | 13 | @doc "Get list of active sessions" 14 | @spec active_sessions() :: map 15 | def active_sessions() do 16 | make_req(:get, "sessions") 17 | end 18 | 19 | 20 | @doc "Creates a session associated with the current pid" 21 | @spec create_session(Hound.Browser.t, map | Keyword.t) :: {:ok, String.t} 22 | def create_session(browser, opts) do 23 | capabilities = make_capabilities(browser, opts) 24 | params = %{ 25 | desiredCapabilities: capabilities 26 | } 27 | 28 | # No retries for this request 29 | make_req(:post, "session", params) 30 | end 31 | 32 | @doc "Make capabilities for session" 33 | @spec make_capabilities(Hound.Browser.t, map | Keyword.t) :: map 34 | def make_capabilities(browser, opts \\ []) do 35 | browser = opts[:browser] || browser 36 | %{ 37 | javascriptEnabled: false, 38 | version: "", 39 | rotatable: false, 40 | takesScreenshot: true, 41 | cssSelectorsEnabled: true, 42 | nativeEvents: false, 43 | platform: "ANY" 44 | } 45 | |> Map.merge(Hound.Browser.make_capabilities(browser, opts)) 46 | |> Map.merge(opts[:driver] || %{}) 47 | end 48 | 49 | @doc "Get capabilities of a particular session" 50 | @spec session_info(String.t) :: map 51 | def session_info(session_id) do 52 | make_req(:get, "session/#{session_id}") 53 | end 54 | 55 | 56 | @doc "Destroy a session" 57 | @spec destroy_session(String.t) :: :ok 58 | def destroy_session(session_id) do 59 | make_req(:delete, "session/#{session_id}") 60 | end 61 | 62 | 63 | @doc "Set the timeout for a particular type of operation" 64 | @spec set_timeout(String.t, String.t, non_neg_integer) :: :ok 65 | def set_timeout(session_id, operation, time) do 66 | make_req(:post, "session/#{session_id}/timeouts", %{type: operation, ms: time}) 67 | end 68 | 69 | 70 | @doc "Get the session log for a particular log type" 71 | @spec fetch_log(String.t, String.t) :: :ok 72 | def fetch_log(session_id, logtype) do 73 | make_req(:post, "session/#{session_id}/log", %{type: logtype}) 74 | end 75 | 76 | 77 | @doc "Get a list of all supported log types" 78 | @spec fetch_log_types(String.t) :: :ok 79 | def fetch_log_types(session_id) do 80 | make_req(:get, "session/#{session_id}/log/types") 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/hound/session_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.SessionServer do 2 | @moduledoc false 3 | 4 | use GenServer 5 | @name __MODULE__ 6 | 7 | def start_link do 8 | GenServer.start_link(__MODULE__, %{}, name: @name) 9 | end 10 | 11 | 12 | def session_for_pid(pid, opts) do 13 | current_session_id(pid) || 14 | change_current_session_for_pid(pid, :default, opts) 15 | end 16 | 17 | 18 | def current_session_id(pid) do 19 | case :ets.lookup(@name, pid) do 20 | [{^pid, _ref, session_id, _all_sessions}] -> session_id 21 | [] -> nil 22 | end 23 | end 24 | 25 | def current_session_name(pid) do 26 | case :ets.lookup(@name, pid) do 27 | [{^pid, _ref, session_id, all_sessions}] -> 28 | Enum.find_value all_sessions, fn 29 | {name, id} when id == session_id -> name 30 | _ -> nil 31 | end 32 | [] -> nil 33 | end 34 | end 35 | 36 | 37 | def change_current_session_for_pid(pid, session_name, opts) do 38 | GenServer.call(@name, {:change_session, pid, session_name, opts}, genserver_timeout()) 39 | end 40 | 41 | 42 | def all_sessions_for_pid(pid) do 43 | case :ets.lookup(@name, pid) do 44 | [{^pid, _ref, _session_id, all_sessions}] -> all_sessions 45 | [] -> %{} 46 | end 47 | end 48 | 49 | 50 | def destroy_sessions_for_pid(pid) do 51 | GenServer.call(@name, {:destroy_sessions, pid}, 60000) 52 | end 53 | 54 | ## Callbacks 55 | 56 | def init(state) do 57 | :ets.new(@name, [:set, :named_table, :protected, read_concurrency: true]) 58 | {:ok, state} 59 | end 60 | 61 | 62 | def handle_call({:change_session, pid, session_name, opts}, _from, state) do 63 | {:ok, driver_info} = Hound.driver_info 64 | 65 | {ref, sessions} = 66 | case :ets.lookup(@name, pid) do 67 | [{^pid, ref, _session_id, sessions}] -> 68 | {ref, sessions} 69 | [] -> 70 | {Process.monitor(pid), %{}} 71 | end 72 | 73 | {session_id, sessions} = 74 | case Map.fetch(sessions, session_name) do 75 | {:ok, session_id} -> 76 | {session_id, sessions} 77 | :error -> 78 | session_id = create_session(driver_info, opts) 79 | {session_id, Map.put(sessions, session_name, session_id)} 80 | end 81 | 82 | :ets.insert(@name, {pid, ref, session_id, sessions}) 83 | {:reply, session_id, Map.put(state, ref, pid)} 84 | end 85 | 86 | def handle_call({:destroy_sessions, pid}, _from, state) do 87 | destroy_sessions(pid) 88 | {:reply, :ok, state} 89 | end 90 | 91 | def handle_info({:DOWN, ref, _, _, _}, state) do 92 | if pid = state[ref] do 93 | destroy_sessions(pid) 94 | end 95 | {:noreply, state} 96 | end 97 | 98 | defp create_session(driver_info, opts) do 99 | case Hound.Session.create_session(driver_info[:browser], opts) do 100 | {:ok, session_id} -> session_id 101 | {:error, reason} -> raise "could not create a new session: #{reason}, check webdriver is running" 102 | end 103 | end 104 | 105 | defp destroy_sessions(pid) do 106 | sessions = all_sessions_for_pid(pid) 107 | :ets.delete(@name, pid) 108 | Enum.each sessions, fn({_session_name, session_id})-> 109 | Hound.Session.destroy_session(session_id) 110 | end 111 | end 112 | 113 | defp genserver_timeout() do 114 | Application.get_env(:hound, :genserver_timeout, 60000) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/hound/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Supervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | def start_link(options \\ []) do 7 | :supervisor.start_link(__MODULE__, [options]) 8 | end 9 | 10 | 11 | def init([options]) do 12 | children = [ 13 | worker(Hound.ConnectionServer, [options]), 14 | worker(Hound.SessionServer, []) 15 | ] 16 | 17 | # See http://elixir-lang.org/docs/stable/Supervisor.Behaviour.html 18 | # for other strategies and supported options 19 | supervise(children, strategy: :one_for_one) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/hound/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Hound.Utils do 2 | @moduledoc false 3 | 4 | def temp_file_path(prefix, extension) do 5 | {{year, month, day}, {hour, minutes, seconds}} = :erlang.localtime() 6 | {:ok, configs} = Hound.configs 7 | "#{configs[:temp_dir]}/#{prefix}-#{year}-#{month}-#{day}-#{hour}-#{minutes}-#{seconds}.#{extension}" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.1.1" 5 | 6 | def project do 7 | [ app: :hound, 8 | version: @version, 9 | elixir: ">= 1.4.0", 10 | description: "Webdriver library for integration testing and browser automation", 11 | source_url: "https://github.com/HashNuke/hound", 12 | deps: deps(), 13 | package: package(), 14 | docs: [source_ref: "#{@version}", extras: ["README.md"], main: "readme"] 15 | ] 16 | end 17 | 18 | 19 | def application do 20 | [ 21 | extra_applications: [:logger], 22 | mod: {Hound, []}, 23 | description: 'Integration testing and browser automation library', 24 | ] 25 | end 26 | 27 | 28 | defp deps do 29 | [ 30 | {:hackney, "~> 1.5"}, 31 | {:jason, "~> 1.1"}, 32 | {:earmark, "~> 1.2", only: :dev}, 33 | {:ex_doc, "~> 0.16", only: :dev} 34 | ] 35 | end 36 | 37 | 38 | defp package do 39 | [ 40 | maintainers: ["Akash Manohar J", "Daniel Perez"], 41 | licenses: ["MIT"], 42 | links: %{ 43 | "GitHub" => "https://github.com/HashNuke/hound", 44 | "Docs" => "http://hexdocs.pm/hound/" 45 | } 46 | ] 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [:rebar3], [], "hexpm", "fdc6066ceeccb3aa14049ab6edf0b9af3b64ae1b0db2a92d5c52146f373bbb1c"}, 3 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm", "000aaeff08919e95e7aea13e4af7b2b9734577b3e6a7c50ee31ee88cab6ec4fb"}, 4 | "ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0e11d67e662142fc3945b0ee410c73c8c956717fbeae4ad954b418747c734973"}, 5 | "hackney": {:hex, :hackney, "1.9.0", "51c506afc0a365868469dcfc79a9d0b94d896ec741cfd5bd338f49a5ec515bfe", [:rebar3], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e38f4a7937b6dfc5fa87403ece26b1826bc81838f09ac57fabf2f7a9885fe818"}, 6 | "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fc1a2f7340c422650504b1662f28fdf381f34cbd30664e8491744e52c9511d40"}, 7 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, 8 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5fbc8e549aa9afeea2847c0769e3970537ed302f93a23ac612602e805d9d1e7f"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "adf0218695e22caeda2820eaba703fa46c91820d53813a2223413da3ef4ba515"}, 10 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 11 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm", "7a4c8e1115a2732a67d7624e28cf6c9f30c66711a9e92928e745c255887ba465"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm", "5c040b8469c1ff1b10093d3186e2e10dbe483cd73d79ec017993fb3985b8a9b3"}, 13 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm", "4f8805eb5c8a939cf2359367cb651a3180b27dfb48444846be2613d79355d65e"}, 14 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm", "da1d9bef8a092cc7e1e51f1298037a5ddfb0f657fe862dfe7ba4c5807b551c29"}, 15 | } 16 | -------------------------------------------------------------------------------- /notes/configuring-hound.md: -------------------------------------------------------------------------------- 1 | ## Configuring Hound 2 | 3 | To configure Hound, use the project's `config/config.exs` file or equivalent (v0.14.0 and above). Here are some examples: 4 | 5 | ```elixir 6 | # Start with selenium driver (default) 7 | config :hound, driver: "selenium" 8 | ``` 9 | 10 | ```elixir 11 | # Use Chrome with the default driver (selenium) 12 | config :hound, driver: "chrome" 13 | ``` 14 | 15 | ```elixir 16 | # Start with default driver at port 1234 and use firefox 17 | config :hound, port: 1234, browser: "firefox" 18 | ``` 19 | 20 | ```elixir 21 | # Start Hound for PhantomJs 22 | config :hound, driver: "phantomjs" 23 | ``` 24 | 25 | ```elixir 26 | # Start Hound for ChromeDriver (default port 9515 assumed) 27 | config :hound, driver: "chrome_driver" 28 | ``` 29 | 30 | ```elixir 31 | # Use Chrome in headless mode with ChromeDriver (default port 9515 assumed) 32 | config :hound, driver: "chrome_driver", browser: "chrome_headless" 33 | ``` 34 | 35 | ```elixir 36 | # Start Hound for remote PhantomJs server at port 5555 37 | config :hound, driver: "phantomjs", host: "http://example.com", port: 5555 38 | ``` 39 | 40 | ```elixir 41 | # Define your application's host and port (defaults to "http://localhost:4001") 42 | config :hound, app_host: "http://localhost", app_port: 4001 43 | ``` 44 | 45 | ```elixir 46 | # Define how long the application will wait between failed attempts (in miliseconds) 47 | config :hound, retry_time: 500 48 | ``` 49 | 50 | ```elixir 51 | # Define http client settings 52 | config :hound, http: [recv_timeout: :infinity, proxy: ["socks5", "127.0.0.1", "9050"]] 53 | ``` 54 | 55 | ```elixir 56 | # Define selenium hub settings 57 | config :hound, 58 | driver: "chrome_driver", 59 | host: "http://localhost", 60 | port: 32770, 61 | path_prefix: "wd/hub/" 62 | ``` 63 | 64 | ```elixir 65 | # Set genserver timeout 66 | config :hound, genserver_timeout: 480000 67 | ``` 68 | 69 | ```elixir 70 | # Set default request retries 71 | config :hound, retries: 3 72 | ``` -------------------------------------------------------------------------------- /notes/simple-browser-automation.md: -------------------------------------------------------------------------------- 1 | Make sure to [configure Hound](https://github.com/HashNuke/hound/blob/master/notes/configuring-hound.md) first, or you will get an error. 2 | 3 | ## Simple browser automation using Hound 4 | 5 | ```elixir 6 | Application.start :hound 7 | 8 | defmodule Example do 9 | use Hound.Helpers 10 | 11 | def run do 12 | Hound.start_session 13 | 14 | navigate_to "http://akash.im" 15 | IO.inspect page_title() 16 | 17 | # Automatically invoked if the session owner process crashes 18 | Hound.end_session 19 | end 20 | end 21 | 22 | Example.run 23 | ``` 24 | -------------------------------------------------------------------------------- /test/browser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.BrowserTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.Browser 5 | 6 | test "make_capabilities for chrome" do 7 | result = Browser.make_capabilities("chrome", metadata: %{"foo" => "bar"}) 8 | assert %{browserName: "chrome", chromeOptions: %{"args" => ["--user-agent=" <> ua]}} = result 9 | assert Hound.Metadata.extract(ua) == %{"foo" => "bar"} 10 | end 11 | 12 | test "make capabilities for firefox" do 13 | result = Browser.make_capabilities("firefox", metadata: %{"foo" => "bar"}) 14 | assert %{browserName: "firefox", firefox_profile: profile} = result 15 | assert {:ok, files} = :zip.extract(Base.decode64!(profile), [:memory]) 16 | assert [{'user.js', user_prefs}] = files 17 | assert [_, ua] = Regex.run(~r{user_pref\("general\.useragent\.override", "(.*?)"\);}, user_prefs) 18 | assert Hound.Metadata.extract(ua) == %{"foo" => "bar"} 19 | end 20 | 21 | test "make_capabilities for phantomjs" do 22 | result = Browser.make_capabilities("phantomjs", metadata: %{"foo" => "bar"}) 23 | assert %{:browserName => "phantomjs", "phantomjs.page.settings.userAgent" => ua} = result 24 | assert Hound.Metadata.extract(ua) == %{"foo" => "bar"} 25 | end 26 | 27 | test "user_agent" do 28 | assert Browser.user_agent(:firefox) =~ "Firefox" 29 | assert Browser.user_agent(:chrome) =~ "Chrome" 30 | assert Browser.user_agent(:iphone) =~ "iPhone" 31 | assert Browser.user_agent(:android) =~ "Android" 32 | assert Browser.user_agent(:phantomjs) =~ "PhantomJS" 33 | end 34 | 35 | test "make_capabilities supports additional_capabilities" do 36 | result = Browser.make_capabilities("firefox", additional_capabilities: %{firefox_profile: :firefox_profile}) 37 | assert %{browserName: "firefox", firefox_profile: :firefox_profile} = result 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/browsers/chrome_headless_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.ChromeHeadlessTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.Browser.ChromeHeadless 5 | 6 | test "default_user_agent" do 7 | assert ChromeHeadless.default_user_agent == :chrome 8 | end 9 | 10 | test "default_capabilities" do 11 | ua = Hound.Browser.user_agent(:iphone) 12 | expected = %{chromeOptions: %{"args" => ["--user-agent=#{ua}", "--headless", "--disable-gpu"]}} 13 | assert ChromeHeadless.default_capabilities(ua) == expected 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/browsers/chrome_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.ChromeTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.Browser.Chrome 5 | 6 | test "default_user_agent" do 7 | assert Chrome.default_user_agent == :chrome 8 | end 9 | 10 | test "default_capabilities" do 11 | ua = Hound.Browser.user_agent(:iphone) 12 | expected = %{chromeOptions: %{"args" => ["--user-agent=#{ua}"]}} 13 | assert Chrome.default_capabilities(ua) == expected 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/browsers/default_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.DefaultTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.Browser.Default 5 | 6 | test "default_user_agent" do 7 | assert Default.default_user_agent == :default 8 | end 9 | 10 | test "default_capabilities" do 11 | ua = Hound.Browser.user_agent(:iphone) 12 | assert Default.default_capabilities(ua) == %{} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/browsers/firefox/profile_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.Firefox.ProfileTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.Browser.Firefox.Profile 5 | 6 | test "new/1 returns a new Profile" do 7 | assert %Profile{} = Profile.new 8 | end 9 | 10 | test "get_preference/2 returns the preference value or nil" do 11 | profile = Profile.new |> Profile.put_preference("foo", "bar") 12 | assert Profile.get_preference(profile, "foo") == "bar" 13 | refute Profile.get_preference(profile, "bar") 14 | end 15 | 16 | test "put_preference/3 set the preference to the profile" do 17 | profile = Profile.new 18 | refute Profile.get_preference(profile, "foo") 19 | profile = Profile.put_preference(profile, "foo", "bar") 20 | assert Profile.get_preference(profile, "foo") == "bar" 21 | end 22 | 23 | test "serialize_preferences/1 returns profile serialized as JS" do 24 | profile = 25 | Profile.new 26 | |> Profile.put_preference("foo", "bar") 27 | |> Profile.put_preference("bar", 3) 28 | serialized = Profile.serialize_preferences(profile) 29 | assert serialized =~ ~s{user_pref("foo", "bar");} 30 | assert serialized =~ ~s{user_pref("bar", 3);} 31 | end 32 | 33 | test "dump/1 returns a valid base64 profile" do 34 | profile = 35 | Profile.new 36 | |> Profile.put_preference("foo", "bar") 37 | |> Profile.put_preference("bar", 3) 38 | assert {:ok, b64_profile} = Profile.dump(profile) 39 | {:ok, files} = :zip.extract(Base.decode64!(b64_profile), [:memory]) 40 | assert [{'user.js', user_prefs}] = files 41 | assert user_prefs =~ ~s{user_pref("foo", "bar");} 42 | assert user_prefs =~ ~s{user_pref("bar", 3);} 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/browsers/firefox_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.FirefoxTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.Browser.Firefox 5 | 6 | test "default_user_agent" do 7 | assert Firefox.default_user_agent == :firefox 8 | end 9 | 10 | test "default_capabilities" do 11 | ua = Hound.Browser.user_agent(:iphone) 12 | assert %{firefox_profile: profile} = Firefox.default_capabilities(ua) 13 | assert {:ok, files} = :zip.extract(Base.decode64!(profile), [:memory]) 14 | assert [{'user.js', user_prefs}] = files 15 | assert user_prefs =~ ~s 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/browsers/phantomjs_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.Browser.PhantomJSTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.Browser.PhantomJS 5 | 6 | test "default_user_agent" do 7 | assert PhantomJS.default_user_agent == :phantomjs 8 | end 9 | 10 | test "default_capabilities" do 11 | ua = Hound.Browser.user_agent(:iphone) 12 | assert PhantomJS.default_capabilities(ua) == %{"phantomjs.page.settings.userAgent" => ua} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/element_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElementTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.Element 5 | 6 | test "encoding to JSON" do 7 | uuid = "some-uuid" 8 | element = %Element{uuid: uuid} 9 | assert Jason.encode!(element) == ~s({"ELEMENT":"#{uuid}"}) 10 | end 11 | 12 | test "string representation" do 13 | uuid = "some-uuid" 14 | element = %Element{uuid: uuid} 15 | assert to_string(element) == uuid 16 | end 17 | 18 | test "element?/1" do 19 | assert Element.element?(%Element{uuid: "foo"}) 20 | refute Element.element?("foo") 21 | end 22 | 23 | test "from_response/1" do 24 | assert Element.from_response(%{"ELEMENT" => "uuid"}) == %Element{uuid: "uuid"} 25 | assert_raise Hound.InvalidElementError, fn -> Element.from_response(%{"value" => "foo"}) end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/exceptions_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HoundNotSupportedErrorTest do 2 | use ExUnit.Case 3 | 4 | test "message" do 5 | {:ok, info} = Hound.ConnectionServer.driver_info 6 | function_name = "foo" 7 | err = try do 8 | raise Hound.NotSupportedError, function: function_name 9 | rescue 10 | e -> Exception.message(e) 11 | end 12 | assert err =~ "not supported" 13 | assert err =~ function_name 14 | assert err =~ info.driver 15 | assert err =~ info.browser 16 | end 17 | 18 | defmodule DummyRaiser do 19 | require Hound.NotSupportedError 20 | 21 | def foo do 22 | Hound.NotSupportedError.raise_for(%{driver: "selenium", browser: "firefox"}) 23 | :ok 24 | end 25 | end 26 | 27 | test "raise_for" do 28 | if match?({:ok, %{driver: "selenium", browser: "firefox"}}, Hound.ConnectionServer.driver_info) do 29 | assert_raise Hound.NotSupportedError, fn -> 30 | DummyRaiser.foo 31 | end 32 | else 33 | assert DummyRaiser.foo == :ok 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/helpers/cookie_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CookieTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | @domain "localhost:9090" 6 | 7 | setup do 8 | Hound.start_session 9 | if Hound.InternalHelpers.driver_supports?("delete_cookies") do 10 | delete_cookies() 11 | end 12 | 13 | parent = self() 14 | on_exit fn-> 15 | # NOTE PhantomJs uses the same cookie jar for all sessions. 16 | # We'll delete cookies after each session, because we only want to test our APIs 17 | Hound.end_session(parent) 18 | end 19 | :ok 20 | end 21 | 22 | 23 | test "should set a cookie" do 24 | navigate_to "http://#{@domain}/page1.html" 25 | cart_id = "12v3q4rsdv" 26 | safe_set_cookie(%{name: "cart_id", value: cart_id}) 27 | valid_cookie = Enum.find(cookies(), fn(cookie)-> cookie["name"]=="cart_id" end) 28 | 29 | assert valid_cookie["value"] == cart_id 30 | end 31 | 32 | 33 | test "should get cookies on page" do 34 | navigate_to "http://#{@domain}/page1.html" 35 | safe_set_cookie(%{name: "example", value: "12v3q4rsdv"}) 36 | 37 | assert length(cookies()) >= 1 38 | end 39 | 40 | 41 | test "should delete a cookie" do 42 | navigate_to "http://#{@domain}/page1.html" 43 | cart_id = "12v3q4rsdv" 44 | safe_set_cookie(%{name: "cart_id", value: cart_id}) 45 | safe_set_cookie(%{name: "cart_status", value: "active"}) 46 | 47 | 48 | assert length(cookies()) >= 2 49 | delete_cookie("cart_id") 50 | assert length(cookies()) >= 1 51 | end 52 | 53 | 54 | test "should delete all cookies" do 55 | navigate_to "http://#{@domain}/page1.html" 56 | cart_id = "12v3q4rsdv" 57 | safe_set_cookie(%{name: "cart_id", value: cart_id}) 58 | safe_set_cookie(%{name: "cart_status", value: "active"}) 59 | 60 | assert length(cookies()) >= 2 61 | delete_cookies() 62 | assert length(cookies()) >= 0 63 | end 64 | 65 | # NOTE: avoids bug with recent versions of phantomjs 66 | # see: https://github.com/ariya/phantomjs/issues/14047 67 | defp safe_set_cookie(cookie, retry \\ true) do 68 | try do 69 | set_cookie(cookie) 70 | rescue 71 | _ -> 72 | if retry, do: safe_set_cookie(Map.put(cookie, :domain, @domain), false) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/helpers/dialog_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DialogTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | if Hound.InternalHelpers.driver_supports?("dialog_text") do 6 | 7 | hound_session() 8 | 9 | test "Get dialog text" do 10 | navigate_to "http://localhost:9090/page1.html" 11 | execute_script("alert('hello')") 12 | assert dialog_text() == "hello" 13 | end 14 | 15 | 16 | test "Dismiss dialog" do 17 | navigate_to "http://localhost:9090/page1.html" 18 | execute_script("return window.isItReal = confirm('Is it true?')") 19 | dismiss_dialog() 20 | assert execute_script("return window.isItReal") == false 21 | end 22 | 23 | 24 | test "Accept dialog" do 25 | navigate_to "http://localhost:9090/page1.html" 26 | execute_script("return window.isItReal = confirm('Is it true?')") 27 | accept_dialog() 28 | assert execute_script("return window.isItReal") == true 29 | end 30 | 31 | 32 | test "Input into prompt" do 33 | navigate_to "http://localhost:9090/page1.html" 34 | execute_script("return window.isItReal = prompt('Is it true?')") 35 | input_into_prompt("Yes it is") 36 | accept_dialog() 37 | assert execute_script("return window.isItReal") == "Yes it is" 38 | end 39 | 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/helpers/element_with_ids_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElementTestWithIds do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | test "should get visible text of an element" do 8 | navigate_to "http://localhost:9090/page1.html" 9 | element = find_element(:class, "example") 10 | assert visible_text(element) == "Paragraph" 11 | end 12 | 13 | 14 | test "should input value into field" do 15 | navigate_to "http://localhost:9090/page1.html" 16 | element = find_element(:name, "username") 17 | 18 | input_into_field(element, "john") 19 | assert attribute_value(element, "value") == "john" 20 | 21 | input_into_field(element, "doe") 22 | assert attribute_value(element, "value") == "johndoe" 23 | end 24 | 25 | 26 | test "should fill a field with a value" do 27 | navigate_to "http://localhost:9090/page1.html" 28 | element = find_element(:name, "username") 29 | 30 | fill_field(element, "johndoe") 31 | assert attribute_value(element, "value") == "johndoe" 32 | 33 | fill_field(element, "janedoe") 34 | assert attribute_value(element, "value") == "janedoe" 35 | end 36 | 37 | 38 | test "should get tag name of element" do 39 | navigate_to "http://localhost:9090/page1.html" 40 | element = find_element(:name, "username") 41 | assert tag_name(element) == "input" 42 | end 43 | 44 | 45 | test "should clear field" do 46 | navigate_to "http://localhost:9090/page1.html" 47 | element = find_element(:name, "username") 48 | fill_field(element, "johndoe") 49 | assert attribute_value(element, "value") == "johndoe" 50 | 51 | clear_field(element) 52 | assert attribute_value(element, "value") == "" 53 | end 54 | 55 | 56 | test "should return true if item is selected in a checkbox or radio" do 57 | navigate_to "http://localhost:9090/page1.html" 58 | element = find_element :id, "speed-superpower" 59 | click element 60 | assert selected?(element) 61 | end 62 | 63 | 64 | test "should return false if item is *not* selected" do 65 | navigate_to "http://localhost:9090/page1.html" 66 | element = find_element :id, "speed-flying" 67 | assert selected?(element) == false 68 | end 69 | 70 | 71 | test "Should return true if element is enabled" do 72 | navigate_to "http://localhost:9090/page1.html" 73 | element = find_element(:name, "username") 74 | assert element_enabled?(element) == true 75 | end 76 | 77 | 78 | test "Should return false if element is *not* enabled" do 79 | navigate_to "http://localhost:9090/page1.html" 80 | element = find_element(:name, "promocode") 81 | assert element_enabled?(element) == false 82 | end 83 | 84 | 85 | test "should get attribute value of an element" do 86 | navigate_to "http://localhost:9090/page1.html" 87 | element = find_element(:class, "example") 88 | assert attribute_value(element, "data-greeting") == "hello" 89 | end 90 | 91 | 92 | test "should return true if an element is displayed" do 93 | navigate_to "http://localhost:9090/page1.html" 94 | element = find_element(:class, "example") 95 | assert element_displayed?(element) 96 | end 97 | 98 | 99 | test "should return false if an element is *not* displayed" do 100 | navigate_to "http://localhost:9090/page1.html" 101 | element = find_element(:class, "hidden-element") 102 | assert element_displayed?(element) == false 103 | end 104 | 105 | 106 | test "should get an element's location on screen" do 107 | navigate_to "http://localhost:9090/page1.html" 108 | element = find_element :class, "example" 109 | {loc_x, loc_y} = element_location(element) 110 | assert is_integer(loc_x) || is_float(loc_x) 111 | assert is_integer(loc_y) || is_float(loc_y) 112 | end 113 | 114 | 115 | test "should get an element's size" do 116 | navigate_to "http://localhost:9090/page1.html" 117 | element = find_element(:class, "example") 118 | size = element_size(element) 119 | assert size == {400, 100} 120 | end 121 | 122 | 123 | test "should get css property of an element" do 124 | navigate_to "http://localhost:9090/page1.html" 125 | element = find_element(:class, "container") 126 | assert css_property(element, "display") == "block" 127 | end 128 | 129 | 130 | test "should click on an element" do 131 | navigate_to "http://localhost:9090/page1.html" 132 | element = find_element(:class, "submit-form") 133 | click element 134 | assert current_url() == "http://localhost:9090/page2.html" 135 | end 136 | 137 | 138 | test "should submit a form element" do 139 | navigate_to "http://localhost:9090/page1.html" 140 | element = find_element(:name, "username") 141 | submit_element(element) 142 | Process.sleep(50) 143 | assert current_url() == "http://localhost:9090/page2.html" 144 | end 145 | 146 | test "should move mouse to an element" do 147 | navigate_to "http://localhost:9090/page1.html" 148 | element = find_element(:id, "mouse-actions") 149 | move_to(element, 5, 5) 150 | assert visible_text({:id, "mouse-actions"}) == "Mouse over" 151 | end 152 | 153 | test "should mouse down on an element" do 154 | navigate_to "http://localhost:9090/page1.html" 155 | element = find_element(:id, "mouse-actions") 156 | move_to(element, 5, 5) 157 | mouse_down() 158 | assert visible_text({:id, "mouse-actions"}) == "Mouse down" 159 | end 160 | 161 | test "should mouse up on an element" do 162 | navigate_to "http://localhost:9090/page1.html" 163 | element = find_element(:id, "mouse-actions") 164 | move_to(element, 5, 5) 165 | # Mouse up needs a mouse down before 166 | mouse_down() 167 | mouse_up() 168 | assert visible_text({:id, "mouse-actions"}) == "Mouse up" 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /test/helpers/element_with_selectors_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElementWithSelectorsTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | test "should get visible text of an element, when selector is passed" do 8 | navigate_to "http://localhost:9090/page1.html" 9 | assert visible_text({:class, "example"}) == "Paragraph" 10 | end 11 | 12 | test "should raise when passed selector does not match any element" do 13 | navigate_to "http://localhost:9090/page1.html" 14 | assert_raise Hound.NoSuchElementError, fn -> 15 | visible_text({:class, "i-dont-exist"}) 16 | end 17 | end 18 | 19 | 20 | test "should input value into field, when selector is passed" do 21 | navigate_to "http://localhost:9090/page1.html" 22 | 23 | element = {:name, "username"} 24 | 25 | input_into_field(element, "john") 26 | assert attribute_value(element, "value") == "john" 27 | 28 | input_into_field(element, "doe") 29 | assert attribute_value(element, "value") == "johndoe" 30 | end 31 | 32 | 33 | test "should fill a field with a value, when selector is passed" do 34 | navigate_to "http://localhost:9090/page1.html" 35 | element = {:name, "username"} 36 | 37 | fill_field(element, "johndoe") 38 | assert attribute_value(element, "value") == "johndoe" 39 | 40 | fill_field(element, "janedoe") 41 | assert attribute_value(element, "value") == "janedoe" 42 | end 43 | 44 | 45 | test "should get tag name of element, when selector is passed" do 46 | navigate_to "http://localhost:9090/page1.html" 47 | assert tag_name({:name, "username"}) == "input" 48 | end 49 | 50 | 51 | test "should clear field, when selector is passed" do 52 | navigate_to "http://localhost:9090/page1.html" 53 | element = {:name, "username"} 54 | 55 | fill_field(element, "johndoe") 56 | assert attribute_value(element, "value") == "johndoe" 57 | 58 | clear_field(element) 59 | assert attribute_value(element, "value") == "" 60 | end 61 | 62 | 63 | test "should return true if item is selected in a checkbox or radio, when selector is passed" do 64 | navigate_to "http://localhost:9090/page1.html" 65 | element = {:id, "speed-superpower"} 66 | click element 67 | assert selected?(element) 68 | end 69 | 70 | 71 | test "should return false if item is *not* selected, when selector is passed" do 72 | navigate_to "http://localhost:9090/page1.html" 73 | assert selected?({:id, "speed-flying"}) == false 74 | end 75 | 76 | 77 | test "Should return true if element is enabled, when selector is passed" do 78 | navigate_to "http://localhost:9090/page1.html" 79 | assert element_enabled?({:name, "username"}) == true 80 | end 81 | 82 | 83 | test "Should return false if element is *not* enabled, when selector is passed" do 84 | navigate_to "http://localhost:9090/page1.html" 85 | assert element_enabled?({:name, "promocode"}) == false 86 | end 87 | 88 | 89 | test "should get attribute value of an element, when selector is passed" do 90 | navigate_to "http://localhost:9090/page1.html" 91 | assert attribute_value({:class, "example"}, "data-greeting") == "hello" 92 | end 93 | 94 | 95 | test "should return true when an element has a class, when selector is passed" do 96 | navigate_to "http://localhost:9090/page1.html" 97 | assert has_class?({:class, "example"}, "example") 98 | assert has_class?({:class, "another_example"}, "another_class") 99 | end 100 | 101 | 102 | test "should return false when an element does not have a class, when selector is passed" do 103 | navigate_to "http://localhost:9090/page1.html" 104 | refute has_class?({:class, "example"}, "ex") 105 | refute has_class?({:class, "example"}, "other") 106 | end 107 | 108 | 109 | test "should return true if an element is displayed, when selector is passed" do 110 | navigate_to "http://localhost:9090/page1.html" 111 | assert element_displayed?({:class, "example"}) 112 | end 113 | 114 | 115 | test "should return false if an element is *not* displayed, when selector is passed" do 116 | navigate_to "http://localhost:9090/page1.html" 117 | assert element_displayed?({:class, "hidden-element"}) == false 118 | end 119 | 120 | 121 | test "should get an element's location on screen, when selector is passed" do 122 | navigate_to "http://localhost:9090/page1.html" 123 | {loc_x, loc_y} = element_location({:class, "example"}) 124 | assert is_integer(loc_x) || is_float(loc_x) 125 | assert is_integer(loc_y) || is_float(loc_y) 126 | end 127 | 128 | 129 | test "should get an element's size, when selector is passed" do 130 | navigate_to "http://localhost:9090/page1.html" 131 | size = element_size({:class, "example"}) 132 | assert size == {400, 100} 133 | end 134 | 135 | 136 | test "should get css property of an element, when selector is passed" do 137 | navigate_to "http://localhost:9090/page1.html" 138 | assert css_property({:class, "container"}, "display") == "block" 139 | end 140 | 141 | 142 | test "should click on an element, when selector is passed" do 143 | navigate_to "http://localhost:9090/page1.html" 144 | click({:class, "submit-form"}) 145 | assert current_url() == "http://localhost:9090/page2.html" 146 | end 147 | 148 | 149 | test "should submit a form element, when selector is passed" do 150 | navigate_to "http://localhost:9090/page1.html" 151 | submit_element({:name, "username"}) 152 | Process.sleep(50) 153 | assert current_url() == "http://localhost:9090/page2.html" 154 | end 155 | 156 | 157 | test "should move mouse to an element" do 158 | navigate_to "http://localhost:9090/page1.html" 159 | move_to({:id, "mouse-actions"}, 5, 5) 160 | assert visible_text({:id, "mouse-actions"}) == "Mouse over" 161 | end 162 | 163 | test "should mouse down on an element" do 164 | navigate_to "http://localhost:9090/page1.html" 165 | move_to({:id, "mouse-actions"}, 5, 5) 166 | mouse_down() 167 | assert visible_text({:id, "mouse-actions"}) == "Mouse down" 168 | end 169 | 170 | test "should mouse up on an element" do 171 | navigate_to "http://localhost:9090/page1.html" 172 | move_to({:id, "mouse-actions"}, 5, 5) 173 | # Mouse up needs a mouse down before 174 | mouse_down() 175 | mouse_up() 176 | assert visible_text({:id, "mouse-actions"}) == "Mouse up" 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /test/helpers/log_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LogTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | test "Should be able to extract log written in javascript" do 8 | navigate_to "http://localhost:9090/page1.html" 9 | 10 | execute_script("console.log(\"Some log\");") 11 | execute_script("console.log(\"Next log\");") 12 | 13 | if is_webdriver_selenium() do 14 | assert_raise Hound.NotSupportedError, "fetch_log() is not supported by driver selenium with browser firefox", fn -> 15 | fetch_log() 16 | end 17 | else 18 | log = fetch_log() 19 | 20 | assert log =~ "Some log" 21 | assert log =~ "Next log" 22 | end 23 | end 24 | 25 | test "Should be able to detect if theres any errors in the javascript" do 26 | navigate_to "http://localhost:9090/page_with_javascript_error.html" 27 | execute_script("console.log(\"Should not return normal logs\");") 28 | 29 | if is_webdriver_selenium() do 30 | assert_raise Hound.NotSupportedError, "fetch_errors() is not supported by driver selenium with browser firefox", fn -> 31 | fetch_errors() 32 | end 33 | else 34 | log = fetch_errors() 35 | refute log =~ "Should not return normal logs" 36 | assert log =~ "This is a javascript error" 37 | end 38 | end 39 | 40 | defp is_webdriver_selenium() do 41 | match?({:ok, %{driver: "selenium"}}, Hound.driver_info) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/helpers/navigation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NavigationTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | test "should get current url" do 8 | url = "http://localhost:9090/page1.html" 9 | navigate_to(url) 10 | assert url == current_url() 11 | end 12 | 13 | test "should get current path" do 14 | url = "http://localhost:9090/page1.html" 15 | navigate_to(url) 16 | assert current_path() == "/page1.html" 17 | end 18 | 19 | test "should navigate to a url" do 20 | url = "http://localhost:9090/page1.html" 21 | 22 | navigate_to(url) 23 | assert( url == current_url() ) 24 | end 25 | 26 | 27 | test "should navigate to a relative url" do 28 | url = "http://localhost:9090/page1.html" 29 | 30 | navigate_to("/page1.html") 31 | assert url == current_url() 32 | end 33 | 34 | test "should navigate backward, forward and refresh" do 35 | url1 = "http://localhost:9090/page1.html" 36 | url2 = "http://localhost:9090/page2.html" 37 | 38 | navigate_to(url1) 39 | assert( url1 == current_url() ) 40 | 41 | navigate_to(url2) 42 | assert( url2 == current_url() ) 43 | 44 | navigate_back() 45 | assert( url1 == current_url() ) 46 | 47 | navigate_forward() 48 | assert( url2 == current_url() ) 49 | 50 | refresh_page() 51 | assert( url2 == current_url() ) 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /test/helpers/orientation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OrientationTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | # hound_session() 6 | 7 | # test "should get current orientation" do 8 | # end 9 | 10 | 11 | # test "should set orientation" do 12 | # end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /test/helpers/page_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PageTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | alias Hound.Element 6 | 7 | hound_session() 8 | 9 | test "should get page source" do 10 | navigate_to("http://localhost:9090/page1.html") 11 | assert(Regex.match?(~r//, page_source())) 12 | end 13 | 14 | test "should get visible page text" do 15 | navigate_to("http://localhost:9090/page1.html") 16 | assert(String.contains? visible_page_text(), "Flying") 17 | assert(not String.contains? visible_page_text(), "This is hidden") 18 | end 19 | 20 | 21 | test "should get page title" do 22 | navigate_to("http://localhost:9090/page1.html") 23 | assert("Hound Test Page" == page_title()) 24 | end 25 | 26 | 27 | test "should get page title encoded with utf8" do 28 | navigate_to("http://localhost:9090/page_utf.html") 29 | assert("This is UTF: zażółć gęślą jaźń" == page_title()) 30 | end 31 | 32 | 33 | test "should find element within page" do 34 | navigate_to("http://localhost:9090/page1.html") 35 | assert Element.element?(find_element(:css, ".example")) 36 | end 37 | 38 | 39 | test "search_element/3 should return {:error, :no_such_element} if element does not exist" do 40 | navigate_to("http://localhost:9090/page1.html") 41 | assert search_element(:css, ".i-dont-exist") == {:error, :no_such_element} 42 | end 43 | 44 | test "find_element/3 should raise NoSuchElementError if element does not exist" do 45 | navigate_to("http://localhost:9090/page1.html") 46 | assert_raise Hound.NoSuchElementError, fn -> 47 | find_element(:css, ".i-dont-exist") 48 | end 49 | end 50 | 51 | test "should find all elements within page" do 52 | navigate_to("http://localhost:9090/page1.html") 53 | elements = find_all_elements(:tag, "p") 54 | assert length(elements) == 6 55 | for element <- elements do 56 | assert Element.element?(element) 57 | end 58 | end 59 | 60 | 61 | test "should find a single element within another element" do 62 | navigate_to("http://localhost:9090/page1.html") 63 | container_id = find_element(:class, "container") 64 | element = find_within_element(container_id, :class, "example") 65 | assert Element.element?(element) 66 | end 67 | 68 | test "search_within_element/4 should return {:error, :no_such_element} if element is not found" do 69 | navigate_to("http://localhost:9090/page1.html") 70 | container_id = find_element(:class, "container") 71 | assert search_within_element(container_id, :class, "i-dont-exist") == {:error, :no_such_element} 72 | end 73 | 74 | test "find_within_element/4 should raise NoSuchElementError if element is not found" do 75 | navigate_to("http://localhost:9090/page1.html") 76 | container_id = find_element(:class, "container") 77 | assert_raise Hound.NoSuchElementError, fn -> find_within_element(container_id, :class, "i-dont-exist") end 78 | end 79 | 80 | test "should find all elements within another element" do 81 | navigate_to("http://localhost:9090/page1.html") 82 | container_id = find_element(:class, "container") 83 | elements = find_all_within_element(container_id, :tag, "p") 84 | assert length(elements) == 2 85 | for element <- elements do 86 | assert Element.element?(element) 87 | end 88 | end 89 | 90 | 91 | test "should get element in focus" do 92 | navigate_to("http://localhost:9090/page1.html") 93 | assert Element.element?(element_in_focus()) 94 | end 95 | 96 | 97 | test "should send text to active element" do 98 | navigate_to("http://localhost:9090/page1.html") 99 | click {:name, "username"} 100 | send_text "test" 101 | send_text "123" 102 | 103 | assert attribute_value({:name, "username"}, "value") == "test123" 104 | end 105 | 106 | end 107 | -------------------------------------------------------------------------------- /test/helpers/save_page_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SavePageTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | test "should save the page" do 8 | navigate_to("http://localhost:9090/page1.html") 9 | path = save_page("screenshot-test.html") 10 | assert File.exists?(path) 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /test/helpers/screenshot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScreenshotTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | test "should take a screenshot" do 8 | navigate_to("http://localhost:9090/page1.html") 9 | path = take_screenshot("screenshot-test.png") 10 | assert File.exists?(path) 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /test/helpers/script_execution_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScriptExecutionTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | test "Execute javascript synchronously" do 8 | navigate_to "http://localhost:9090/page1.html" 9 | assert execute_script("return(arguments[0] + arguments[1]);", [1, 2]) == 3 10 | end 11 | 12 | 13 | test "Execute javascript asynchronously" do 14 | navigate_to "http://localhost:9090/page1.html" 15 | assert execute_script_async("arguments[arguments.length-1]('hello')", []) == "hello" 16 | end 17 | 18 | 19 | test "Pass element to javascript" do 20 | navigate_to "http://localhost:9090/page1.html" 21 | element = find_element(:id, "speed-flying") 22 | assert execute_script("return(arguments[0].value);", [element]) == "flying" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/helpers/window_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WindowTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | test "should set and get the window size" do 8 | set_window_size current_window_handle(), 600, 400 9 | {width, height} = window_size(current_window_handle()) 10 | assert width == 600 11 | assert height == 400 12 | end 13 | 14 | 15 | test "should maximize the window" do 16 | set_window_size current_window_handle(), 0, 0 17 | {width, height} = window_size(current_window_handle()) 18 | assert width > 0 19 | assert height > 0 20 | end 21 | 22 | 23 | test "switch to a frame" do 24 | navigate_to "http://localhost:9090/page1.html" 25 | assert length(find_all_elements :class, "child-para") == 0 26 | 27 | focus_frame(0) 28 | assert length(find_all_elements :class, "child-para") > 0 29 | end 30 | 31 | test "focus window" do 32 | navigate_to "http://localhost:9090/page1.html" 33 | assert Enum.count(window_handles()) == 1 34 | execute_script("window.open('http://localhost:9090/page2.html')", []) 35 | assert Enum.count(window_handles()) == 2 36 | window_handles() |> Enum.at(1) |> focus_window() 37 | assert current_url() == "http://localhost:9090/page2.html" 38 | window_handles() |> Enum.at(0) |> focus_window() 39 | assert current_url() == "http://localhost:9090/page1.html" 40 | end 41 | 42 | test "close window" do 43 | navigate_to "http://localhost:9090/page1.html" 44 | assert Enum.count(window_handles()) == 1 45 | execute_script("window.open('http://localhost:9090/page2.html')", []) 46 | assert Enum.count(window_handles()) == 2 47 | window_handles() |> Enum.at(1) |> focus_window() 48 | close_current_window() 49 | window_handles() |> Enum.at(0) |> focus_window() 50 | assert Enum.count(window_handles()) == 1 51 | assert current_url() == "http://localhost:9090/page1.html" 52 | end 53 | 54 | if Hound.InternalHelpers.driver_supports?("focus_parent_frame") do 55 | test "switch to a frame and switch back to parent frame" do 56 | navigate_to "http://localhost:9090/page1.html" 57 | assert length(find_all_elements :class, "child-para") == 0 58 | 59 | focus_frame(0) 60 | assert length(find_all_elements :class, "child-para") > 0 61 | 62 | focus_parent_frame() 63 | assert length(find_all_elements :class, "child-para") == 0 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/hound_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HoundTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | setup do 6 | Hound.start_session 7 | parent = self() 8 | on_exit fn-> Hound.end_session(parent) end 9 | :ok 10 | end 11 | 12 | test "should return driver info" do 13 | {:ok, driver_info} = Hound.driver_info 14 | assert driver_info[:driver_type] == nil 15 | end 16 | 17 | 18 | test "should return the current session ID" do 19 | assert is_binary(Hound.current_session_id) 20 | end 21 | 22 | 23 | test "Should destroy all sessions for current process" do 24 | Hound.end_session 25 | assert Hound.SessionServer.all_sessions_for_pid(self()) == %{} 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /test/matchers.exs: -------------------------------------------------------------------------------- 1 | defmodule MatcherTests do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | #visible_text_in_page? 8 | test "should return true when text is visible" do 9 | navigate_to "http://localhost:9090/page1.html" 10 | assert visible_in_page?(~r/Paragraph/) 11 | end 12 | 13 | 14 | test "should return true when text is loaded by javascript" do 15 | navigate_to "http://localhost:9090/page1.html" 16 | :timer.sleep(1000) 17 | assert visible_in_page?(~r/Javascript/) 18 | end 19 | 20 | 21 | test "should return false when text is *not* visible" do 22 | navigate_to "http://localhost:9090/page1.html" 23 | assert visible_in_page?(~r/hidden/) == false 24 | end 25 | 26 | 27 | # visible_text_in_element? 28 | test "should return true when text is visible inside block" do 29 | navigate_to "http://localhost:9090/page1.html" 30 | assert visible_in_element?({:class, "container"}, ~r/Another Paragraph/) 31 | end 32 | 33 | 34 | test "should return true when text is loaded by javascript inside block" do 35 | navigate_to "http://localhost:9090/page1.html" 36 | :timer.sleep(1000) 37 | assert visible_in_element?({:id, "javascript"}, ~r/Javascript/) 38 | end 39 | 40 | test "should return false when text is *not* visible inside element" do 41 | navigate_to "http://localhost:9090/page1.html" 42 | assert visible_in_element?({:class, "hidden-wrapper"}, ~r/hidden/) == false 43 | end 44 | 45 | 46 | test "should return true if element is present" do 47 | navigate_to "http://localhost:9090/page1.html" 48 | assert element?(:css, ".container") 49 | end 50 | 51 | 52 | test "should return false if element is *not* present" do 53 | navigate_to "http://localhost:9090/page1.html" 54 | assert element?(:css, ".contra") == false 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/metadata_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.MetadataTest do 2 | use ExUnit.Case 3 | 4 | use Hound.Helpers 5 | 6 | alias Hound.Metadata 7 | 8 | @ua "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1" 9 | 10 | test "format produces UA compatible string" do 11 | assert Metadata.format(%{foo: "foo", bar: "baz"}) =~ ~r{BeamMetadata \([a-zA-Z0-9=-_]+\)} 12 | end 13 | 14 | test "extract returns empty map when no match" do 15 | assert Metadata.extract(@ua) == %{} 16 | end 17 | 18 | test "extract raises when data cannot be parsed" do 19 | assert_raise ArgumentError, fn -> 20 | Metadata.extract("#{@ua}/BeamMetadata (some random string)") 21 | end 22 | 23 | assert_raise Hound.InvalidMetadataError, fn -> 24 | bad_data = {:v123, "foobar"} |> :erlang.term_to_binary |> Base.encode64 25 | Metadata.extract("#{@ua}/BeamMetadata (#{bad_data})") 26 | end 27 | end 28 | 29 | test "extract returns metadata" do 30 | ua = Metadata.append(@ua, %{foo: "bar", baz: "qux"}) 31 | assert Metadata.extract(ua) == %{foo: "bar", baz: "qux"} 32 | end 33 | 34 | test "accepts complex values" do 35 | metadata = %{ref: make_ref(), pid: self(), list: [1, 2, 3]} 36 | assert Metadata.extract(Metadata.append(@ua, metadata)) == metadata 37 | end 38 | 39 | test "metadata is passed to the browser through user agent" do 40 | metadata = %{my_pid: self()} 41 | Hound.start_session(metadata: metadata) 42 | navigate_to "http://localhost:9090/page1.html" 43 | ua = execute_script("return navigator.userAgent;", []) 44 | assert Metadata.extract(ua) == metadata 45 | Hound.end_session 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/multiple_browser_session_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MultipleBrowserSessionTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session() 6 | 7 | test "should be able to run multiple sessions" do 8 | url1 = "http://localhost:9090/page1.html" 9 | url2 = "http://localhost:9090/page2.html" 10 | 11 | # Navigate to a url 12 | navigate_to(url1) 13 | 14 | # Change to another session 15 | change_session_to :another_session 16 | # Navigate to a url in the second session 17 | navigate_to(url2) 18 | # Then assert url 19 | assert url2 == current_url() 20 | 21 | # Now go back to the default session 22 | change_to_default_session() 23 | # Assert if the url is the one we visited 24 | assert url1 == current_url() 25 | end 26 | 27 | test "should be able to run multiple sessions using in_browser_session" do 28 | url1 = "http://localhost:9090/page1.html" 29 | url2 = "http://localhost:9090/page2.html" 30 | 31 | # Navigate to a url 32 | navigate_to(url1) 33 | 34 | # In another session... 35 | in_browser_session :another_session, fn-> 36 | navigate_to(url2) 37 | assert url2 == current_url() 38 | end 39 | 40 | # Assert if the url is the one we visited 41 | assert url1 == current_url() 42 | end 43 | 44 | test "should preserve session after using in_browser_session" do 45 | url1 = "http://localhost:9090/page1.html" 46 | url2 = "http://localhost:9090/page2.html" 47 | url3 = "http://localhost:9090/page3.html" 48 | 49 | # Navigate to url1 in default session 50 | navigate_to(url1) 51 | 52 | # Change to a second session and navigate to url2 53 | change_session_to :session_a 54 | navigate_to(url2) 55 | 56 | # In a third session... 57 | in_browser_session :session_b, fn -> 58 | navigate_to(url3) 59 | assert url3 == current_url() 60 | end 61 | 62 | # Assert the current url is the url we visited in :session_a 63 | assert url2 == current_url() 64 | 65 | # Switch back to the default session 66 | change_session_to :default 67 | 68 | # Assert the current url is the one we visited in the default session 69 | assert url1 == current_url() 70 | end 71 | 72 | test "in_browser_session should return the result of the given function" do 73 | url1 = "http://localhost:9090/page1.html" 74 | 75 | # In another session, navigate to url1 and return the current url 76 | result = 77 | in_browser_session :another_session, fn -> 78 | navigate_to(url1) 79 | current_url() 80 | end 81 | 82 | assert result == url1 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/response_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ResponseParserTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.ResponseParser 5 | 6 | defmodule DummyParser do 7 | use Hound.ResponseParser 8 | end 9 | 10 | defmodule DummyCustomErrorParser do 11 | use Hound.ResponseParser 12 | 13 | def handle_error(%{"message" => "some error"}) do 14 | "custom handler" 15 | end 16 | end 17 | 18 | test "parse/2" do 19 | body = ~s({"sessionId": 1}) 20 | assert ResponseParser.parse(DummyParser, "session", 200, [], body) == {:ok, 1} 21 | end 22 | 23 | test "parse/2 with 204 no-content response" do 24 | assert ResponseParser.parse(DummyParser, "session", 204, [], "") == :ok 25 | end 26 | 27 | test "handle_response/3 session" do 28 | assert DummyParser.handle_response("session", 200, %{"sessionId" => 1}) == {:ok, 1} 29 | assert DummyParser.handle_response("session", 400, %{"sessionId" => 1}) == :error 30 | end 31 | 32 | test "handle_response/3 errors" do 33 | body = %{"value" => %{"message" => "error"}} 34 | assert DummyParser.handle_response("foo", 200, body) == {:error, "error"} 35 | body = %{"value" => %{"message" => "some error"}} 36 | assert DummyCustomErrorParser.handle_response("foo", 200, body) == "custom handler" 37 | body = %{"value" => %{"message" => "other error"}} 38 | assert DummyCustomErrorParser.handle_response("foo", 200, body) == {:error, "other error"} 39 | end 40 | 41 | test "handle_response/3 value" do 42 | assert DummyParser.handle_response("foo", 200, %{"status" => 0, "value" => "value"}) == "value" 43 | end 44 | 45 | test "handle_response/3 success" do 46 | assert DummyParser.handle_response("foo", 200, "whatever") == :ok 47 | end 48 | 49 | test "handle_response/3 error" do 50 | assert DummyParser.handle_response("foo", 500, "whatever") == :error 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/sample_pages/iframe.html: -------------------------------------------------------------------------------- 1 |

This text is within a frame

2 | -------------------------------------------------------------------------------- /test/sample_pages/page1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hound Test Page 5 | 19 | 20 | 21 |
22 |

Paragraph

23 |

Another Paragraph

24 |
25 | 26 |
This is hidden
27 | 28 |

Out of container paragraph

29 | 30 |
31 | 32 | 33 | 34 |

35 | Flying 36 |
37 | Speed 38 |

39 | 40 | 41 |

42 | Apple 43 |
44 | Custard Apple 45 |

46 | 47 | 48 |
49 | 50 | 51 |
52 |

This is hidden

53 |
54 | 55 |
56 | I need action 57 |
58 | 59 | 60 |
61 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /test/sample_pages/page2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hound Another Test Page 5 | 6 | 7 |

Paragraph

8 | 9 | -------------------------------------------------------------------------------- /test/sample_pages/page3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hound Yet Another Test Page 5 | 6 | 7 |

Paragraph

8 | 9 | 10 | -------------------------------------------------------------------------------- /test/sample_pages/page_utf.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | This is UTF: zażółć gęślą jaźń 6 | 7 | 8 |

This is UTF: zażółć gęślą jaźń

9 | 10 | 11 | -------------------------------------------------------------------------------- /test/sample_pages/page_with_javascript_error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hound Test Page 5 | 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/session_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hound.SessionTest do 2 | use ExUnit.Case 3 | 4 | alias Hound.Session 5 | 6 | test "make_capabilities uses default browser" do 7 | assert %{browserName: "chrome"} = Session.make_capabilities("chrome") 8 | end 9 | 10 | test "make_capabilities has default settings" do 11 | assert %{takesScreenshot: true} = Session.make_capabilities("chrome") 12 | end 13 | 14 | test "make_capabilities allows browser override" do 15 | assert %{browserName: "firefox"} = Session.make_capabilities("chrome", browser: "firefox") 16 | end 17 | 18 | test "make_capabilities uses driver overrides" do 19 | assert %{foo: "bar"} = Session.make_capabilities("chrome", driver: %{foo: "bar"}) 20 | end 21 | 22 | test "make_capabilities overrides user agent" do 23 | ua = Hound.Browser.user_agent(:chrome) 24 | result = Session.make_capabilities("chrome", user_agent: ua) 25 | assert %{chromeOptions: %{"args" => ["--user-agent=" <> ^ua]}} = result 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.start :inets 2 | 3 | server_root = '#{Path.absname("test/sample_pages")}' 4 | test_server_config = [ 5 | port: 9090, 6 | server_name: 'hound_test_server', 7 | server_root: server_root, 8 | document_root: server_root, 9 | bind_address: {127, 0, 0, 1} 10 | ] 11 | 12 | {:ok, pid} = :inets.start(:httpd, test_server_config) 13 | 14 | IO.puts "Stopping Hound and restarting with options for test suite..." 15 | :ok = Application.stop(:hound) 16 | Hound.Supervisor.start_link( 17 | driver: System.get_env("WEBDRIVER"), 18 | app_port: 9090 19 | ) 20 | 21 | System.at_exit fn(_exit_status) -> 22 | :ok = :inets.stop(:httpd, pid) 23 | end 24 | 25 | ExUnit.start [max_cases: 5] 26 | -------------------------------------------------------------------------------- /test/tools/start_webdriver.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$WEBDRIVER" = "phantomjs" ]; then 4 | npm install -g phantomjs 5 | nohup phantomjs -w & 6 | echo "Running with PhantomJs..." 7 | sleep 3 8 | elif [ "$WEBDRIVER" = "selenium" ]; then 9 | mkdir -p $HOME/src 10 | wget https://selenium-release.storage.googleapis.com/3.141/selenium-server-standalone-3.141.59.jar 11 | nohup java -jar selenium-server-standalone-3.141.59.jar & 12 | echo "Running with Selenium..." 13 | sleep 10 14 | fi 15 | --------------------------------------------------------------------------------