defmodule Wallaby.Chrome do
@moduledoc """
The Chrome driver uses [Chromedriver](https://sites.google.com/a/chromium.org/chromedriver/) to power Google Chrome and Chromium.
## Usage
Start a Wallaby Session using this driver with the following command:
```elixir
{:ok, session} = Wallaby.start_session()
```
## Configuration
### Headless
Chrome will run in headless mode by default.
You can disable this behaviour using the following configuration.
This will override the default capabilities and capabilities set with application configuration.
This will _not_ override capabilities passed in directly to `Wallaby.start_session/1`.
```elixir
config :wallaby,
chromedriver: [
headless: false
]
```
### Capabilities
These capabilities will override the default capabilities.
```elixir
config :wallaby,
chromedriver: [
capabilities: %{
# something
}
]
```
### ChromeDriver binary
If ChromeDriver is not available in your path, you can specify it's location.
```elixir
config :wallaby,
chromedriver: [
path: "path/to/chrome"
]
```
### Chrome binary
This configures which instance of Google Chrome to use.
This will override the default capabilities and capabilities set with application configuration.
This will _not_ override capabilities passed in directly to `Wallaby.start_session/1`.
```elixir
config :wallaby,
chromedriver: [
binary: "path/to/chrome"
]
```
## Default Capabilities
By default, Chromdriver will use the following capabilities
You can read more about capabilities in the [JSON Wire Protocol](https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#capabilities-json-object) documentation and the [Chromedriver](https://sites.google.com/a/chromium.org/chromedriver/capabilities) documentation.
```elixir
%{
javascriptEnabled: false,
loadImages: false,
version: "",
rotatable: false,
takesScreenshot: true,
cssSelectorsEnabled: true,
nativeEvents: false,
platform: "ANY",
unhandledPromptBehavior: "accept",
loggingPrefs: %{
browser: "DEBUG"
},
chromeOptions: %{
args: [
"--no-sandbox",
"window-size=1280,800",
"--disable-gpu",
"--headless",
"--fullscreen",
"--user-agent=Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"
]
}
}
```
## Notes
This driver requires [Chromedriver](https://sites.google.com/a/chromium.org/chromedriver/) to be installed in your path.
"""
use Supervisor
@behaviour Wallaby.Driver
@default_readiness_timeout 10_000
alias Wallaby.Chrome.Chromedriver
alias Wallaby.WebdriverClient
alias Wallaby.{DependencyError, Metadata}
import Wallaby.Driver.LogChecker
@typedoc """
Options to pass to Wallaby.start_session/1
```elixir
Wallaby.start_session(
capabilities: %{chromeOptions: %{args: ["--headless"]}},
create_session_fn: fn url, capabilities ->
WebdriverClient.create_session(url, capabilities)
end
)
```
* `:capabilities` - capabilities to pass to chromedriver on session startup
* `create_session_fn` - Deprecated and to be removed
* `:readiness_timeout` - milliseconds to wait for chromedriver server to be ready
before raising a timeout error. (Default: #{@default_readiness_timeout})
"""
@type start_session_opts ::
{:capabilities, map}
| {:readiness_timeout, timeout()}
| {:create_session_fn, (String.t(), map -> {:ok, %{}})}
@doc false
def start_link(opts \\ []) do
Supervisor.start_link(__MODULE__, :ok, opts)
end
@doc false
def init(_) do
children = [
Wallaby.Driver.LogStore,
{PartitionSupervisor,
child_spec: Wallaby.Chrome.Chromedriver,
name: Wallaby.Chromedrivers,
partitions: min(System.schedulers_online(), 10)}
]
Supervisor.init(children, strategy: :one_for_one)
end
@doc false
@spec validate() :: :ok | {:error, DependencyError.t()}
def validate do
with {:ok, chromedriver_version} <- get_chromedriver_version(),
{:ok, chrome_version} <- get_chrome_version(),
:ok <- minimum_version_check(chromedriver_version) do
version_compare(chrome_version, chromedriver_version)
end
end
@doc false
@spec get_chrome_version() :: {:ok, list(String.t())} | {:error, term}
def get_chrome_version do
case :os.type() do
{:win32, :nt} ->
{stdout, 0} =
System.cmd("reg", [
"query",
"HKLM\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Google Chrome"
])
chrome_version = parse_version(stdout)
{:ok, chrome_version}
_ ->
case find_chrome_executable() do
{:ok, chrome_executable} ->
{stdout, 0} = System.cmd(chrome_executable, ["--version"])
chrome_version = parse_version(stdout)
{:ok, chrome_version}
error ->
error
end
end
end
@doc false
@spec get_chromedriver_version() :: {:ok, list(String.t())} | {:error, term}
def get_chromedriver_version do
case find_chromedriver_executable() do
{:ok, chromedriver_executable} ->
{stdout, 0} = System.cmd(chromedriver_executable, ["--version"])
chromedriver_version = parse_version(stdout)
{:ok, chromedriver_version}
error ->
error
end
end
@doc false
@spec find_chrome_executable :: {:ok, String.t()} | {:error, DependencyError.t()}
def find_chrome_executable do
default_chrome_paths =
case :os.type() do
{:unix, :darwin} ->
[
"/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium"
]
{:unix, :linux} ->
["google-chrome", "chromium", "chromium-browser"]
{:win32, :nt} ->
["C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"]
end
chrome_path =
:wallaby
|> Application.get_env(:chromedriver, [])
|> Keyword.get(:binary, [])
[Path.expand(chrome_path) | default_chrome_paths]
|> Enum.find_value(&System.find_executable/1)
|> case do
path when is_binary(path) ->
{:ok, path}
nil ->
exception =
DependencyError.exception("""
Wallaby can't find Chrome. Make sure you have chrome installed and included in your path.
You can also provide a path using `config :wallaby, :chromedriver, binary: <path>`.
""")
{:error, exception}
end
end
@doc false
@spec find_chromedriver_executable :: {:ok, String.t()} | {:error, DependencyError.t()}
def find_chromedriver_executable do
chromedriver_path =
:wallaby
|> Application.get_env(:chromedriver, [])
|> Keyword.get(:path, "chromedriver")
[Path.expand(chromedriver_path), chromedriver_path]
|> Enum.find_value(&System.find_executable/1)
|> case do
path when is_binary(path) ->
{:ok, path}
nil ->
exception =
DependencyError.exception("""
Wallaby can't find chromedriver. Make sure you have chromedriver installed and included in your path.
You can also provide a path using `config :wallaby, :chromedriver, path: <path>`.
""")
{:error, exception}
end
end
defp version_compare(chrome_version, chromedriver_version) do
case chrome_version == chromedriver_version do
true ->
:ok
_ ->
exception =
DependencyError.exception("""
Looks like you're trying to run Wallaby with a mismatched version of Chrome: #{Enum.join(chrome_version, ".")} and chromedriver: #{Enum.join(chromedriver_version, ".")}.
Chrome and chromedriver must match to a major, minor, and build version.
""")
IO.warn(exception.message)
:ok
end
end
defp minimum_version_check([major_version, _minor_version, _build_version])
when major_version > 2 do
:ok
end
defp minimum_version_check([major_version, minor_version, _build_version])
when major_version == 2 and minor_version >= 30 do
:ok
end
defp minimum_version_check([major_version, minor_version])
when major_version == 2 and minor_version >= 30 do
:ok
end
defp minimum_version_check(_version) do
exception =
DependencyError.exception("""
Looks like you're trying to run an older version of chromedriver. Wallaby needs at least
chromedriver 2.30 to run correctly.
""")
{:error, exception}
end
defp parse_version(body) do
result =
case Regex.run(~r/.*?(\d+\.\d+(\.\d+)?)/, body) do
[_, version, _] ->
String.split(version, ".")
[_, version] ->
String.split(version, ".")
end
Enum.map(result, &String.to_integer/1)
end
@doc false
@spec start_session([start_session_opts]) :: Wallaby.Driver.on_start_session() | no_return
def start_session(opts \\ []) do
opts |> Keyword.get(:readiness_timeout, @default_readiness_timeout) |> wait_until_ready!()
base_url = Chromedriver.base_url()
create_session_fn = Keyword.get(opts, :create_session_fn, &WebdriverClient.create_session/2)
capabilities =
opts
|> Keyword.get_lazy(:capabilities, fn -> capabilities_from_config(opts) end)
|> put_beam_metadata(opts)
with {:ok, response} <- create_session_fn.(base_url, capabilities) do
id = response["sessionId"]
session = %Wallaby.Session{
session_url: base_url <> "session/#{id}",
url: base_url <> "session/#{id}",
id: id,
driver: __MODULE__,
server: Chromedriver,
capabilities: capabilities
}
if window_size = Keyword.get(opts, :window_size),
do: {:ok, _} = set_window_size(session, window_size[:width], window_size[:height])
{:ok, session}
end
end
defp capabilities_from_config(opts) do
:wallaby
|> Application.get_env(:chromedriver, [])
|> Keyword.get_lazy(:capabilities, &default_capabilities/0)
|> put_headless_config(opts)
|> put_binary_config(opts)
end
@spec wait_until_ready!(timeout) :: :ok | no_return
defp wait_until_ready!(timeout) do
case Chromedriver.wait_until_ready(timeout) do
:ok -> :ok
{:error, :timeout} -> raise "timeout waiting for chromedriver to be ready"
end
end
@doc false
def end_session(%Wallaby.Session{} = session, opts \\ []) do
end_session_fn = Keyword.get(opts, :end_session_fn, &WebdriverClient.delete_session/1)
end_session_fn.(session)
:ok
end
@doc false
def blank_page?(session) do
case current_url(session) do
{:ok, url} ->
url == "data:,"
_ ->
false
end
end
defp delegate(fun, element_or_session, args \\ []) do
check_logs!(element_or_session, fn ->
apply(WebdriverClient, fun, [element_or_session | args])
end)
end
@doc false
defdelegate accept_alert(session, fun), to: WebdriverClient
@doc false
defdelegate dismiss_alert(session, fun), to: WebdriverClient
@doc false
defdelegate accept_confirm(session, fun), to: WebdriverClient
@doc false
defdelegate dismiss_confirm(session, fun), to: WebdriverClient
@doc false
defdelegate accept_prompt(session, input, fun), to: WebdriverClient
@doc false
defdelegate dismiss_prompt(session, fun), to: WebdriverClient
@doc false
defdelegate parse_log(log), to: Wallaby.Chrome.Logger
@doc false
def window_handle(session), do: delegate(:window_handle, session)
@doc false
def window_handles(session), do: delegate(:window_handles, session)
@doc false
def focus_window(session, window_handle), do: delegate(:focus_window, session, [window_handle])
@doc false
def close_window(session), do: delegate(:close_window, session)
@doc false
def get_window_size(session), do: delegate(:get_window_size, session)
@doc false
def set_window_size(session, width, height),
do: delegate(:set_window_size, session, [width, height])
@doc false
def get_window_position(session), do: delegate(:get_window_position, session)
@doc false
def set_window_position(session, x, y), do: delegate(:set_window_position, session, [x, y])
@doc false
def maximize_window(session), do: delegate(:maximize_window, session)
@doc false
def focus_frame(session, frame), do: delegate(:focus_frame, session, [frame])
@doc false
def focus_parent_frame(session), do: delegate(:focus_parent_frame, session)
@doc false
def cookies(session), do: delegate(:cookies, session)
@doc false
def current_path(session), do: delegate(:current_path, session)
@doc false
def current_url(session), do: delegate(:current_url, session)
@doc false
def page_title(session), do: delegate(:page_title, session)
@doc false
def page_source(session), do: delegate(:page_source, session)
@doc false
def set_cookie(session, key, value, attributes \\ []),
do: delegate(:set_cookie, session, [key, value, attributes])
@doc false
def visit(session, url), do: delegate(:visit, session, [url])
@doc false
def attribute(element, name), do: delegate(:attribute, element, [name])
@doc false
def click(element), do: delegate(:click, element)
@doc false
def click(parent, button), do: delegate(:click, parent, [button])
@doc false
def double_click(parent), do: delegate(:double_click, parent)
@doc false
def button_down(parent, button), do: delegate(:button_down, parent, [button])
@doc false
def button_up(parent, button), do: delegate(:button_up, parent, [button])
@doc false
def hover(element), do: delegate(:move_mouse_to, element, [element])
@doc false
def move_mouse_by(parent, x_offset, y_offset),
do: delegate(:move_mouse_to, parent, [nil, x_offset, y_offset])
@doc false
def touch_down(session, element, x_or_offset, y_or_offset),
do: delegate(:touch_down, session, [element, x_or_offset, y_or_offset])
@doc false
def touch_up(session), do: delegate(:touch_up, session)
@doc false
def tap(element), do: delegate(:tap, element)
@doc false
def touch_move(parent, x, y), do: delegate(:touch_move, parent, [x, y])
@doc false
def touch_scroll(element, x_offset, y_offset),
do: delegate(:touch_scroll, element, [x_offset, y_offset])
@doc false
def clear(element), do: delegate(:clear, element)
@doc false
def displayed(element), do: delegate(:displayed, element)
@doc false
def selected(element), do: delegate(:selected, element)
@doc false
def set_value(element, value), do: delegate(:set_value, element, [value])
@doc false
def text(element), do: delegate(:text, element)
@doc false
def execute_script(session_or_element, script, args \\ [], opts \\ []) do
check_logs = Keyword.get(opts, :check_logs, true)
request_fn = fn ->
WebdriverClient.execute_script(session_or_element, script, args)
end
if check_logs do
check_logs!(session_or_element, request_fn)
else
request_fn.()
end
end
@doc false
def execute_script_async(session_or_element, script, args \\ [], opts \\ []) do
check_logs = Keyword.get(opts, :check_logs, true)
request_fn = fn ->
WebdriverClient.execute_script_async(session_or_element, script, args)
end
if check_logs do
check_logs!(session_or_element, request_fn)
else
request_fn.()
end
end
@doc false
def find_elements(session_or_element, compiled_query),
do: delegate(:find_elements, session_or_element, [compiled_query])
@doc false
def send_keys(session_or_element, keys), do: delegate(:send_keys, session_or_element, [keys])
@doc false
def element_size(element), do: delegate(:element_size, element)
@doc false
def element_location(element), do: delegate(:element_location, element)
@doc false
def take_screenshot(session_or_element), do: delegate(:take_screenshot, session_or_element)
@doc false
defdelegate log(session_or_element), to: WebdriverClient
@doc false
def default_capabilities do
chrome_options =
maybe_put_chrome_executable(%{
args: [
"--no-sandbox",
"window-size=1280,800",
"--disable-gpu",
"--headless",
"--fullscreen",
"--user-agent=Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"
]
})
%{
javascriptEnabled: false,
loadImages: false,
version: "",
rotatable: false,
takesScreenshot: true,
cssSelectorsEnabled: true,
nativeEvents: false,
platform: "ANY",
unhandledPromptBehavior: "accept",
loggingPrefs: %{
browser: "DEBUG"
},
chromeOptions: chrome_options
}
end
defp maybe_put_chrome_executable(chrome_options) do
case find_chrome_executable() do
{:ok, chrome_binary} -> Map.put(chrome_options, :binary, chrome_binary)
_ -> chrome_options
end
end
defp put_headless_config(capabilities, opts) do
headless? = resolve_opt(opts, :headless)
capabilities
|> update_unless_nil(:args, headless?, fn args ->
if headless? do
(args ++ ["--headless"])
|> Enum.uniq()
else
args -- ["--headless"]
end
end)
end
defp put_binary_config(capabilities, opts) do
binary_path = resolve_opt(opts, :binary)
capabilities
|> update_unless_nil(:binary, binary_path, fn _ ->
binary_path
end)
end
defp put_beam_metadata(capabilities, opts) do
capabilities
|> update_in([:chromeOptions, :args], fn args ->
for arg <- args do
case String.split(arg, "=") do
["--user-agent", user_agent] ->
new_user_agent = Metadata.append(user_agent, opts[:metadata])
"--user-agent=#{new_user_agent}"
_ ->
arg
end
end
end)
end
defp resolve_opt(opts, key) do
case Keyword.fetch(opts, key) do
{:ok, value} -> value
:error -> Application.get_env(:wallaby, :chromedriver, []) |> Keyword.get(key)
end
end
defp update_unless_nil(capabilities, _key, nil, _updater), do: capabilities
defp update_unless_nil(capabilities, key, _, updater) do
capabilities
|> update_in([:chromeOptions, key], updater)
end
end