Packages
wallaby
0.18.1
0.31.0
0.30.12
0.30.11
0.30.10
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.1
0.29.0
0.28.1
0.28.0
0.27.0
0.26.2
0.26.1
0.26.0
0.25.1
0.25.0
0.24.1
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.2
0.19.1
0.19.0
0.18.1
0.18.0
0.17.0
0.16.1
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.1
0.3.0
0.2.0
0.1.0
0.0.1
Concurrent feature tests for elixir
Current section
Files
Jump to
Current section
Files
lib/wallaby/browser.ex
defmodule Wallaby.Browser do
@moduledoc """
The Browser module is the entrypoint for interacting with a real browser.
By default, action only work with elements that are visible to a real user.
## Actions
Actions are used to interact with form elements. All actions work with the
query interface:
```html
<label for="first_name">
First Name
</label>
<input id="user_first_name" type="text" name="first_name">
```
```
fill_in(page, Query.text_field("First Name"), with: "Grace")
fill_in(page, Query.text_field("first_name"), with: "Grace")
fill_in(page, Query.text_field("user_first_name"), with: "Grace")
```
These queries work with any of the available actions.
```
fill_in(page, Query.text_field("First Name"), with: "Chris")
clear(page, Query.text_field("user_email"))
click(page, Query.radio_button("Radio Button 1"))
click(page, Query.checkbox("Checkbox"))
click(page, Query.checkbox("Checkbox"))
click(page, Query.option("Option 1"))
click(page, Query.button("Some Button"))
attach_file(page, Query.file_field("Avatar"), path: "test/fixtures/avatar.jpg")
```
Actions return their parent element so that they can be chained together:
```
page
|> find(Query.css(".signup-form"), fn(form) ->
form
|> fill_in(Query.text_field("Name"), with: "Grace Hopper")
|> fill_in(Query.text_field("Email"), with: "grace@hopper.com")
|> click(Query.button("Submit"))
end)
```
## Scoping
Finders provide scoping like so:
```
session
|> visit("/page.html")
|> find(Query.css(".users"))
|> find(Query.css(".user", count: 3))
|> List.first
|> find(Query.css(".user-name"))
```
If a callback is passed to find then the scoping will only apply to the callback
and the parent will be passed to the next action in the chain:
```
page
|> find(Query.css(".todo-form"), fn(form) ->
form
|> fill_in(Query.text_field("What needs doing?"), with: "Write Wallaby Documentation")
|> click(Query.button("Save"))
end)
|> find(Query.css(".success-notification"), fn(notification) ->
assert notification
|> has_text?("Todo created successfully!")
end)
```
This allows you to create a test that is logically grouped together in a single pipeline.
It also means that its easy to create re-usable helper functions without having to worry about
chaining. You could re-write the above example like this:
```
def create_todo(page, todo) do
find(Query.css(".todo-form"), & fill_in_and_save_todo(&1, todo))
end
def fill_in_and_save_todo(form, todo) do
form
|> fill_in(Query.text_field("What needs doing?"), with: todo)
|> click(Query.button("Save"))
end
def todo_was_created?(page) do
find Query.css(page, ".success-notification"), fn(notification) ->
assert notification
|> has_text?("Todo created successfully!")
end
end
assert page
|> create_todo("Write Wallaby Documentation")
|> todo_was_created?
"""
alias Wallaby.Element
alias Wallaby.Query
alias Wallaby.Query.ErrorMessage
alias Wallaby.Session
@type t :: any()
@opaque session :: Session.t
@opaque element :: Element.t
@opaque queryable :: Query.t
| Element.t
@type parent :: element
| session
@type locator :: String.t
@type opts :: Query.opts()
@default_max_wait_time 3_000
@doc """
Attempts to synchronize with the browser. This is most often used to
execute queries repeatedly until it either exceeds the time limit or
returns a success.
## Note
It is possible that this function never halts. Whenever we experience a stale
reference error we retry the query without checking to see if we've run over
our time. In practice we should eventually be able to query the dom in a stable
state. However, if this error does continue to occur it will cause wallaby to
loop forever (or until the test is killed by exunit).
"""
@opaque sync_result :: {:ok, any()} | {:error, any()}
@spec retry((() -> sync_result), timeout) :: sync_result()
def retry(f, start_time \\ current_time()) do
case f.() do
{:ok, result} ->
{:ok, result}
{:error, :stale_reference} ->
retry(f, start_time)
{:error, :invalid_selector} ->
{:error, :invalid_selector}
{:error, e} ->
if max_time_exceeded?(start_time) do
{:error, e}
else
retry(f, start_time)
end
end
end
@doc """
Fills in an element identified by `query` with `value`.
All inputs previously present in the input field will be overridden.
### Examples
page
|> fill_in(Query.text_field("name"), with: "Chris")
|> fill_in(Query.css("#password_field", with: "secret42"))
"""
@spec fill_in(parent, Query.t, with: String.t) :: parent
def fill_in(parent, query, with: value) do
parent
|> find(query, &(Element.fill_in(&1, with: value)))
end
# @doc """
# Clears an input field. Input elements are looked up by id, label text, or name.
# The element can also be passed in directly.
# """
@spec clear(parent, Query.t) :: parent
def clear(parent, query) do
parent
|> find(query, &Element.clear/1)
end
@doc """
Attaches a file to a file input. Input elements are looked up by id, label text,
or name.
"""
@spec attach_file(parent, Query.t, path: String.t) :: parent
def attach_file(parent, query, path: path) do
parent
|> set_value(query, :filename.absname(path))
end
@doc """
Takes a screenshot of the current window.
Screenshots are saved to a "screenshots" directory in the same directory the
tests are run in.
"""
@spec take_screenshot(parent) :: parent
def take_screenshot(%{driver: driver} = screenshotable) do
image_data =
screenshotable
|> driver.take_screenshot
path = path_for_screenshot()
File.write! path, image_data
Map.update(screenshotable, :screenshots, [], &(&1 ++ [path]))
end
@doc """
Gets the size of the session's window.
"""
@spec window_size(parent) :: %{String.t => pos_integer, String.t => pos_integer}
def window_size(%Session{driver: driver} = session) do
{:ok, size} = driver.get_window_size(session)
size
end
@doc """
Sets the size of the sessions window.
"""
@spec resize_window(parent, pos_integer, pos_integer) :: parent
def resize_window(%Session{driver: driver} = session, width, height) do
{:ok, _} = driver.set_window_size(session, width, height)
session
end
@doc """
Gets the current url of the session
"""
@spec current_url(parent) :: String.t
def current_url(%Session{driver: driver} = session) do
{:ok, url} = driver.current_url(session)
url
end
@doc """
Gets the current path of the session
"""
@spec current_path(parent) :: String.t
def current_path(%Session{driver: driver} = session) do
{:ok, path} = driver.current_path(session)
path
end
@doc """
Gets the title for the current page
"""
@spec page_title(parent) :: String.t
def page_title(%Session{driver: driver} = session) do
{:ok, title} = driver.page_title(session)
title
end
@doc """
Executes javascript synchoronously, taking as arguments the script to execute,
an optional list of arguments available in the script via `arguments`, and an
optional callback function with the result of script execution as a parameter.
"""
@spec execute_script(parent, String.t) :: parent
@spec execute_script(parent, String.t, list) :: parent
@spec execute_script(parent, String.t, ((binary()) -> any())) :: parent
@spec execute_script(parent, String.t, list, ((binary()) -> any())) :: parent
def execute_script(session, script) do
execute_script(session, script, [])
end
def execute_script(session, script, arguments) when is_list(arguments) do
execute_script(session, script, arguments, fn(_) -> nil end )
end
def execute_script(session, script, callback) when is_function(callback) do
execute_script(session, script, [], callback)
end
def execute_script(%{driver: driver} = parent, script, arguments, callback) when is_list(arguments) and is_function(callback) do
{:ok, value} = driver.execute_script(parent, script, arguments)
callback.(value)
parent
end
@doc """
Sends a list of key strokes to active element. If strings are included
then they are sent as individual keys. Special keys should be provided as a
list of atoms, which are automatically converted into the corresponding key
codes.
For a list of available key codes see `Wallaby.Helpers.KeyCodes`.
## Example
iex> Wallaby.Session.send_keys(session, ["Example Text", :enter])
iex> Wallaby.Session.send_keys(session, [:enter])
iex> Wallaby.Session.send_keys(session, [:shift, :enter])
"""
@spec send_keys(parent, Query.t, Element.keys_to_send) :: parent
@spec send_keys(parent, Element.keys_to_send) :: parent
def send_keys(parent, query, list) do
find(parent, query, fn(element) ->
element
|> Element.send_keys(list)
end)
end
def send_keys(%Element{}=element, keys) do
Element.send_keys(element, keys)
end
def send_keys(parent, keys) when is_binary(keys) do
send_keys(parent, [keys])
end
def send_keys(%{driver: driver} = parent, keys) when is_list(keys) do
{:ok, _} = driver.send_keys(parent, keys)
parent
end
@doc """
Retrieves the source of the current page.
"""
@spec page_source(parent) :: String.t
def page_source(%Session{driver: driver} = session) do
{:ok, source} = driver.page_source(session)
source
end
@doc """
Sets the value of an element. The allowed type for the value depends on the
type of the element. The value may be:
* a string of characters for a text element
* :selected for a radio button, checkbox or select list option
* :unselected for a checkbox
"""
@spec set_value(parent, Query.t, Element.value) :: parent
def set_value(parent, query, :selected) do
find(parent, query, fn(element) ->
case Element.selected?(element) do
true -> :ok
false -> Element.click(element)
end
end)
end
def set_value(parent, query, :unselected) do
find(parent, query, fn(element) ->
case Element.selected?(element) do
false -> :ok
true -> Element.click(element)
end
end)
end
def set_value(parent, query, value) do
find(parent, query, fn(element) ->
element
|> Element.set_value(value)
end)
end
@doc """
Clicks a element.
"""
@spec click(parent, Query.t) :: parent
def click(parent, query) do
parent
|> find(query, &Element.click/1)
end
@doc """
Gets the Element's text value.
"""
@spec text(parent) :: String.t
@spec text(parent, Query.t) :: String.t
def text(parent, query) do
parent
|> find(query)
|> Element.text
end
def text(%Session{}=session) do
session
|> find(Query.css("body"))
|> Element.text()
end
@doc """
Gets the value of the elements attribute.
"""
@spec attr(parent, Query.t, String.t) :: String.t | nil
def attr(parent, query, name) do
parent
|> find(query)
|> Element.attr(name)
end
@doc """
Checks if the element has been selected. Alias for checked?(element)
"""
@spec selected?(parent, Query.t) :: boolean()
def selected?(parent, query) do
parent
|> find(query)
|> Element.selected?
end
@doc """
Checks if the element is visible on the page
"""
@spec visible?(parent, Query.t) :: boolean()
def visible?(parent, query) do
parent
|> has?(query)
end
@doc """
Finds a specific DOM element on the page based on a css selector. Blocks until
it either finds the element or until the max time is reached. By default only
1 element is expected to match the query. If more elements are present then a
count can be specified. Use `:any` to allow any number of elements to be present.
By default only elements that are visible on the page are returned.
Selections can be scoped by providing a Element as the locator for the query.
By default finders only work with elements that would be visible to a real
user.
"""
@spec find(parent, Query.t, ((Element.t) -> any())) :: parent
@spec find(parent, Query.t) :: Element.t | [Element.t]
@spec find(parent, locator) :: Element.t | [Element.t]
def find(parent, %Query{}=query, callback) when is_function(callback) do
results = find(parent, query)
callback.(results)
parent
end
def find(parent, %Query{}=query) do
case execute_query(parent, query) do
{:ok, query} ->
query
|> Query.result
{:error, {:not_found, result}} ->
query = %Query{query | result: result}
if Wallaby.screenshot_on_failure? do
take_screenshot(parent)
end
case validate_html(parent, query) do
{:ok, _} ->
raise Wallaby.QueryError, ErrorMessage.message(query, :not_found)
{:error, html_error} ->
raise Wallaby.QueryError, ErrorMessage.message(query, html_error)
end
{:error, e} ->
if Wallaby.screenshot_on_failure? do
take_screenshot(parent)
end
raise Wallaby.QueryError, ErrorMessage.message(query, e)
end
end
@doc """
Finds all of the DOM elements that match the css selector. If no elements are
found then an empty list is immediately returned. This is equivalent to calling
`find(session, css("element", count: nil, minimum: 0))`.
"""
@spec all(parent, Query.t) :: [Element.t]
def all(parent, %Query{}=query) do
find(
parent,
%Query{query | conditions: Keyword.merge(query.conditions, [count: nil, minimum: 0])})
end
@doc """
Validates that the query returns a result. This can be used to define other
types of matchers.
"""
@spec has?(parent, Query.t) :: boolean()
def has?(parent, query) do
case execute_query(parent, query) do
{:ok, _} -> true
{:error, _} -> false
end
end
@doc """
Matches the Element's value with the provided value.
"""
@spec has_value?(parent, Query.t, any()) :: boolean()
@spec has_value?(Element.t, any()) :: boolean()
def has_value?(parent, query, value) do
parent
|> find(query)
|> has_value?(value)
end
def has_value?(%Element{}=element, value) do
result = retry fn ->
cond do
Element.value(element) == value ->
{:ok, true}
true ->
{:error, false}
end
end
case result do
{:ok, true} ->
true
{:error, false} ->
false
end
end
@doc """
Matches the Element's content with the provided text
"""
@spec has_text?(Element.t, String.t) :: boolean()
@spec has_text?(parent, Query.t, String.t) :: boolean()
def has_text?(parent, query, text) do
parent
|> find(query)
|> has_text?(text)
end
def has_text?(%Session{}=session, text) when is_binary(text) do
session
|> find(Query.css("body"))
|> has_text?(text)
end
def has_text?(parent, text) when is_binary(text) do
result = retry fn ->
cond do
Element.text(parent) =~ text ->
{:ok, true}
true ->
{:error, false}
end
end
case result do
{:ok, true} ->
true
{:error, false} ->
false
end
end
@doc """
Matches the Element's content with the provided text and raises if not found
"""
@spec assert_text(Element.t, String.t) :: boolean()
@spec assert_text(parent, Query.t, String.t) :: boolean()
def assert_text(parent, query, text) when is_binary(text) do
parent
|> find(query)
|> assert_text(text)
end
def assert_text(parent, text) when is_binary(text) do
cond do
has_text?(parent, text) -> true
true -> raise Wallaby.ExpectationNotMet, "Text '#{text}' was not found."
end
end
@doc """
Checks if `query` is present within `parent` and raises if not found.
Returns the given `parent` if the assertion is correct so that it is easily
pipeable.
## Examples
session
|> visit("/")
|> assert_has(Query.css(".login-button"))
"""
defmacro assert_has(parent, query) do
quote do
parent = unquote(parent)
query = unquote(query)
with {:ok, _query_result} <- execute_query(parent, query) do
parent
else
error ->
if Wallaby.screenshot_on_failure? do
take_screenshot(parent)
end
case error do
{:error, {:not_found, results}} ->
query = %Query{query | result: results}
raise Wallaby.ExpectationNotMet,
Query.ErrorMessage.message(query, :not_found)
{:error, :invalid_selector} ->
raise Wallaby.QueryError,
Query.ErrorMessage.message(query, :invalid_selector)
_ ->
raise Wallaby.ExpectationNotMet,
"Wallaby has encountered an internal error: #{inspect error} with session: #{inspect parent}"
end
end
end
end
@doc """
Checks if `query` is not present within `parent` and raises if it is found.
Returns the given `parent` if the query is not found so that it is easily
pipeable.
## Examples
session
|> visit("/")
|> refute_has(Query.css(".secret-admin-content"))
"""
defmacro refute_has(parent, query) do
quote do
parent = unquote(parent)
query = unquote(query)
case execute_query(parent, query) do
{:error, _not_found} ->
parent
{:ok, query} ->
raise Wallaby.ExpectationNotMet,
Query.ErrorMessage.message(query, :found)
end
end
end
@doc """
Searches for CSS on the page.
"""
@spec has_css?(parent, Query.t, String.t) :: boolean()
@spec has_css?(parent, locator) :: boolean()
def has_css?(parent, query, css) when is_binary(css) do
parent
|> find(query)
|> has?(Query.css(css, count: :any))
end
def has_css?(parent, css) when is_binary(css) do
parent
|> find(Wallaby.Query.css(css, count: :any))
|> Enum.any?
end
@doc """
Searches for css that should not be on the page
"""
@spec has_no_css?(parent, Query.t, String.t) :: boolean()
@spec has_no_css?(parent, locator) :: boolean()
def has_no_css?(parent, query, css) when is_binary(css) do
parent
|> find(query)
|> has?(Query.css(css, count: 0))
end
def has_no_css?(parent, css) when is_binary(css) do
parent
|> has?(Query.css(css, count: 0))
end
@doc """
Changes the current page to the provided route.
Relative paths are appended to the provided base_url.
Absolute paths do not use the base_url.
"""
@spec visit(session, String.t) :: session
def visit(%Session{driver: driver} = session, path) do
uri = URI.parse(path)
cond do
uri.host == nil && String.length(base_url()) == 0 ->
raise Wallaby.NoBaseUrl, path
uri.host ->
driver.visit(session, path)
true ->
driver.visit(session, request_url(path))
end
session
end
def cookies(%Session{driver: driver} = session) do
{:ok, cookies_list} = driver.cookies(session)
cookies_list
end
def set_cookie(%Session{driver: driver} = session, key, value) do
if blank_page?(session) do
raise Wallaby.CookieException
end
case driver.set_cookie(session, key, value) do
{:ok, _list} ->
session
{:error, :invalid_cookie_domain} ->
raise Wallaby.CookieException
end
end
defp blank_page?(%Session{driver: driver}=session) do
driver.blank_page?(session)
end
@doc """
Accepts all subsequent JavaScript dialogs in the given session.
"""
def accept_dialogs(%Session{driver: driver} = session) do
driver.accept_dialogs(session)
session
end
@doc """
Dismisses all subsequent JavaScript dialogs in the given session.
"""
def dismiss_dialogs(%Session{driver: driver} = session) do
driver.dismiss_dialogs(session)
session
end
@doc """
Accepts one alert dialog, which must be triggered within the specified `fun`.
Returns the message that was presented to the user. For example:
```
message = accept_alert session, fn(s) ->
click(s, Query.link("Trigger alert"))
end
```
"""
def accept_alert(%Session{driver: driver} = session, fun) do
driver.accept_alert(session, fun)
end
@doc """
Accepts one confirmation dialog, which must be triggered within the specified
`fun`. Returns the message that was presented to the user. For example:
```
message = accept_confirm session, fn(s) ->
click(s, Query.link("Trigger confirm"))
end
```
"""
def accept_confirm(%Session{driver: driver} = session, fun) do
driver.accept_confirm(session, fun)
end
@doc """
Dismisses one confirmation dialog, which must be triggered within the
specified `fun`. Returns the message that was presented to the user. For
example:
```
message = dismiss_confirm session, fn(s) ->
click(s, Query.link("Trigger confirm"))
end
```
"""
def dismiss_confirm(%Session{driver: driver} = session, fun) do
driver.dismiss_confirm(session, fun)
end
@doc """
Accepts one prompt, which must be triggered within the specified `fun`. The
`[with: value]` option allows to simulate user input for the prompt. If no
value is provided, the default value that was passed to `window.prompt` will
be used instead. Returns the message that was presented to the user. For
example:
```
message = accept_prompt session, fn(s) ->
click(s, Query.link("Trigger prompt"))
end
```
Example providing user input:
```
message = accept_prompt session, [with: "User input"], fn(s) ->
click(s, Query.link("Trigger prompt"))
end
```
"""
def accept_prompt(%Session{} = session, fun) do
do_accept_prompt(session, nil, fun)
end
def accept_prompt(%Session{} = session, [with: input_value], fun) when is_binary(input_value) do
do_accept_prompt(session, input_value, fun)
end
defp do_accept_prompt(%Session{driver: driver} = session, input_value, fun) do
driver.accept_prompt(session, input_value, fun)
end
@doc """
Dismisses one prompt, which must be triggered within the specified `fun`.
Returns the message that was presented to the user. For example:
```
message = dismiss_prompt session, fn(s) ->
click(s, Query.link("Trigger prompt"))
end
```
"""
def dismiss_prompt(%Session{driver: driver} = session, fun) do
driver.dismiss_prompt(session, fun)
end
defp validate_html(parent, %{html_validation: :button_type}=query) do
buttons = all(parent, Query.css("button", [text: query.selector]))
cond do
Enum.count(buttons) == 1 && Enum.any?(buttons) ->
{:error, :button_with_bad_type}
true ->
{:ok, query}
end
end
defp validate_html(parent, %{html_validation: :bad_label}=query) do
label_query = Query.css("label", text: query.selector)
labels = all(parent, label_query)
cond do
Enum.count(labels) == 1 ->
cond do
Enum.any?(labels, &(missing_for?(&1))) ->
{:error, :label_with_no_for}
label=List.first(labels) ->
{:error, {:label_does_not_find_field, Element.attr(label, "for")}}
end
true ->
{:ok, query}
end
end
defp validate_html(_, query), do: {:ok, query}
defp missing_for?(element) do
Element.attr(element, "for") == nil
end
defp validate_visibility(query, elements) do
visible = Query.visible?(query)
{:ok, Enum.filter(elements, &(Element.visible?(&1) == visible))}
end
defp validate_count(query, elements) do
cond do
Query.matches_count?(query, Enum.count(elements)) ->
{:ok, elements}
true ->
{:error, {:not_found, elements}}
end
end
defp validate_text(query, elements) do
text = Query.inner_text(query)
if text do
{:ok, Enum.filter(elements, &(matching_text?(&1, text)))}
else
{:ok, elements}
end
end
defp matching_text?(%Element{driver: driver} = element, text) do
case driver.text(element) do
{:ok, element_text} ->
element_text =~ ~r/#{Regex.escape(text)}/
{:error, _} ->
false
end
end
def execute_query(%{driver: driver} = parent, query) do
retry fn ->
try do
with {:ok, query} <- Query.validate(query),
compiled_query <- Query.compile(query),
{:ok, elements} <- driver.find_elements(parent, compiled_query),
{:ok, elements} <- validate_visibility(query, elements),
{:ok, elements} <- validate_text(query, elements),
{:ok, elements} <- validate_count(query, elements)
do
{:ok, %Query{query | result: elements}}
end
rescue
Wallaby.StaleReferenceException ->
{:error, :stale_reference}
end
end
end
defp max_time_exceeded?(start_time) do
current_time() - start_time > max_wait_time()
end
defp current_time do
:erlang.monotonic_time(:milli_seconds)
end
defp max_wait_time do
Application.get_env(:wallaby, :max_wait_time, @default_max_wait_time)
end
defp request_url(path) do
base_url() <> path
end
defp base_url do
Application.get_env(:wallaby, :base_url) || ""
end
defp path_for_screenshot do
File.mkdir_p!(screenshot_dir())
"#{screenshot_dir()}/#{:erlang.system_time}.png"
end
defp screenshot_dir do
Application.get_env(:wallaby, :screenshot_dir) || "#{File.cwd!()}/screenshots"
end
end