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.CookieError
  alias Wallaby.Element
  alias Wallaby.ExpectationNotMetError
  alias Wallaby.NoBaseUrlError
  alias Wallaby.Query
  alias Wallaby.Query.ErrorMessage
  alias Wallaby.Session
  alias Wallaby.StaleReferenceError

  @type t :: any()

  @typep session :: Session.t()
  @typep element :: Element.t()
  @opaque queryable ::
            Query.t()
            | Element.t()

  @type parent ::
          element
          | session
  @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).
  """
  @type sync_result :: {:ok, any()} | {:error, any()}
  @spec retry((() -> sync_result), non_neg_integer()) :: 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")

  ### Note

  Currently, ChromeDriver only supports [BMP Unicode](http://www.unicode.org/roadmaps/bmp/) characters. Emojis are [SMP](https://www.unicode.org/roadmaps/smp/) characters and will be ignored by ChromeDriver.

  Using JavaScript is a known workaround for filling in fields with Emojis and other non-BMP characters.
  """
  @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.

  When dealing with Selenium server (especially a remote instance), the file
  must be uploaded to Selenium via `send_keys` recognizing a local file,
  else attaching a file fails.
  """
  @spec attach_file(parent, Query.t(), path: String.t()) :: parent
  def attach_file(parent, query, path: path) do
    case is_selenium?(parent) do
      true ->
        # local/remote selenium will only properly attach
        # & upload a local file with `send_keys`
        send_keys(parent, query, List.wrap(path))

      false ->
        # retain existing chrome compatibility
        set_value(parent, query, :filename.absname(path))
    end
  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.

  Pass `[{:name, "some_name"}]` to specify the file name. Defaults to a timestamp.
  Pass `[{:log, true}]` to log the location of the screenshot to stdout. Defaults to false.
  """
  @type take_screenshot_opt :: {:name, String.t()} | {:log, boolean}
  @spec take_screenshot(parent, [take_screenshot_opt]) :: parent

  def take_screenshot(%{driver: driver} = screenshotable, opts \\ []) do
    image_data =
      screenshotable
      |> driver.take_screenshot

    name =
      opts
      |> Keyword.get(:name, :erlang.system_time())
      |> to_string
      |> remove_illegal_characters

    path = path_for_screenshot(name)

    try do
      write_screenshot!(path, image_data)

      if opts[:log] do
        IO.puts("Screenshot taken, find it at #{build_file_url(path)}")
      end

      Map.update(screenshotable, :screenshots, [], &(&1 ++ [path]))
    rescue
      _ ->
        IO.puts("\nFailed to make a screenshot")

        screenshotable
    end
  end

  defp remove_illegal_characters(string), do: String.replace(string, ~r{<>:"/\\\?\*}, "")

  @doc """
  Gets the window handle of the current window.

  The window is either an instance of a browser tab or another operating system window.
  Getting the current window handle makes it easy to return to the window in case you
  need to switch between them.

  ## Usage

  ```elixir
  feature "can open a new tab and switch back to the original tab", %{session: session} do
    handle =
      session
      |> visit("/home")
      |> window_handle()

    path =
      session
      # click a link that takes you to a new tab
      |> click(Query.link("External Page"))
      |> assert_text("Some text")
      |> focus_window(handle)
      |> current_path()

    assert "/home" == path
  end
  ```
  """
  @spec window_handle(session :: Session.t()) :: String.t()
  def window_handle(%{driver: driver} = session) do
    {:ok, handle} = driver.window_handle(session)

    handle
  end

  @doc """
  Gets the window handles of all available windows.

  The window is either an instance of a browser tab or another operating system window.

  ## Usage

  ```elixir
  feature "can open new tabs for external links", %{session: session} do
    handles =
      session
      |> visit("/home")
      |> click(Query.link("External Page"))
      |> click(Query.link("Another External Page"))
      |> window_handles()

    assert 3 == length(path)
  end
  ```
  """
  @spec window_handles(session :: Session.t()) :: [String.t()]
  def window_handles(%{driver: driver} = session) do
    {:ok, handles} = driver.window_handles(session)

    handles
  end

  @doc """
  Focuses the window identified by the given handle.

  The window is either an instance of a browser tab or another operating system window.

  ## Usage

  ```elixir
  feature "can switch between different tabs", %{session: session} do
    handle =
      session
      |> visit("/home")
      |> window_handle()

    path =
      session
      # click a link that takes you to a new tab
      |> click(Query.link("External Page"))
      |> assert_text("Some text")
      |> focus_window(handle)
      |> current_path()

    assert "/home" == path
  end
  ```
  """
  @spec focus_window(session :: Session.t(), window_handle :: String.t()) :: parent
  def focus_window(%{driver: driver} = session, window_handle) do
    {:ok, _} = driver.focus_window(session, window_handle)

    session
  end

  @doc """
  Closes the current window.

  The window is either an instance of a browser tab or another operating system window.

  ## Usage

  ```elixir
  feature "closing a window focuses the previously focused window", %{session: session} do
    original_handle =
      session
      |> visit("/home")
      |> window_handle()

    new_handle =
      session
      # click a link that takes you to a new tab
      |> click(Query.link("External Page"))
      |> close_window()
      |> window_handle()

    assert original_handle == new_handle
  end
  ```
  """
  @spec close_window(session :: Session.t()) :: Session.t()
  def close_window(%{driver: driver} = session) do
    {:ok, _} = driver.close_window(session)

    session
  end

  @doc """
  Gets the size of the current window.

  The window is either an instance of a browser tab or another operating system window.

  This is useful for debugging responsive designs where the layout changes as the window size changes. The default window size is 1280x800.

  ## Usage

  ```elixir
  feature "gets the size of the current window", %{session: session} do
    %{"width" => width, "height" => height} =
      session
      |> visit("/home")
      |> window_size()

    assert width == 1280
    assert height == 800
  end
  ```
  """
  @spec window_size(session :: Session.t()) :: %{
          String.t() => pos_integer,
          String.t() => pos_integer
        }
  def window_size(%{driver: driver} = session) do
    {:ok, size} = driver.get_window_size(session)

    size
  end

  @doc """
  Sets the size of the current window.

  The window is either an instance of a browser tab or another operating system window.

  ## Usage

  ```elixir
  feature "sets the size of the window to mobile dimensions", %{session: session} do
    %{"width" => width, "height" => height} =
      session
      |> visit("/home")
      |> resize_window(375, 667)
      |> window_size()

    assert width == 375
    assert height == 667
  end
  ```
  """
  @spec resize_window(session :: Session.t(), width :: pos_integer(), height :: pos_integer()) ::
          Session.t()
  def resize_window(%{driver: driver} = session, width, height) do
    {:ok, _} = driver.set_window_size(session, width, height)

    session
  end

  @doc """
  Maximizes the current window.

  The window is either an instance of a browser tab or another operating system window.

  For most browsers, this requires a graphical window manager to be running.

  ## Usage

  ```elixir
  feature "maximizes the window to the full size of the display", %{session: session} do
    %{"width" => width, "height" => height} =
      session
      |> visit("/home")
      |> maximize_window()
      |> window_size()

    assert width == 1920
    assert height == 1080
  end
  ```
  """
  @spec maximize_window(session :: Session.t()) :: Session.t()
  def maximize_window(%{driver: driver} = session) do
    {:ok, _} = driver.maximize_window(session)

    session
  end

  @doc """
  Gets the position of the current window.

  The window is either an instance of a browser tab or another operating system window.

  ## Usage

  ```elixir
  feature "gets the current display position of the window", %{session: session} do
    %{"x" => x, "y" => y} =
      session
      |> visit("/home")
      |> window_position()

    assert x == 200
    assert y == 200
  end
  ```
  """
  @spec window_position(session :: Session.t()) :: %{
          String.t() => pos_integer,
          String.t() => pos_integer
        }
  def window_position(%{driver: driver} = session) do
    {:ok, position} = driver.get_window_position(session)

    position
  end

  @doc """
  Sets the position of the current window.

  The window is either an instance of a browser tab or another operating system window.

  ## Usage

  ```elixir
  feature "gets the current display position of the window", %{session: session} do
    %{"x" => x, "y" => y} =
      session
      |> visit("/home")
      |> move_window(500, 500)
      |> window_position()

    assert x == 500
    assert y == 500
  end
  ```
  """
  @spec move_window(session :: Session.t(), x :: pos_integer(), y :: pos_integer()) :: Session.t()
  def move_window(%{driver: driver} = session, x, y) do
    {:ok, _} = driver.set_window_position(session, x, y)

    session
  end

  @doc """
  Changes the driver focus to the frame found by query.
  """
  @spec focus_frame(parent, Query.t()) :: parent
  def focus_frame(%{driver: driver} = session, %Query{} = query) do
    session
    |> find(query, &driver.focus_frame(session, &1))
  end

  @doc """
  Changes the driver focus to the parent frame.
  """
  @spec focus_parent_frame(parent) :: parent

  def focus_parent_frame(%{driver: driver} = session) do
    {:ok, _} = driver.focus_parent_frame(session)
    session
  end

  @doc """
  Changes the driver focus to the default (top level) frame.
  """
  @spec focus_default_frame(parent) :: parent

  def focus_default_frame(%{driver: driver} = session) do
    {:ok, _} = driver.focus_frame(session, nil)
    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 synchronously, 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 """
  Executes asynchronous JavaScript, 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_async(parent, String.t()) :: parent
  @spec execute_script_async(parent, String.t(), list) :: parent
  @spec execute_script_async(parent, String.t(), (binary() -> any())) :: parent
  @spec execute_script_async(parent, String.t(), list, (binary() -> any())) :: parent

  def execute_script_async(session, script) do
    execute_script_async(session, script, [])
  end

  def execute_script_async(session, script, arguments) when is_list(arguments) do
    execute_script_async(session, script, arguments, fn _ -> nil end)
  end

  def execute_script_async(session, script, callback) when is_function(callback) do
    execute_script_async(session, script, [], callback)
  end

  def execute_script_async(%{driver: driver} = parent, script, arguments, callback)
      when is_list(arguments) and is_function(callback) do
    {:ok, value} = driver.execute_script_async(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.Browser.send_keys(session, ["Example Text", :enter])
      iex> Wallaby.Browser.send_keys(session, [:enter])
      iex> Wallaby.Browser.send_keys(session, [:shift, :enter])

  ### Note

  Currently, ChromeDriver only supports [BMP Unicode](http://www.unicode.org/roadmaps/bmp/) characters. Emojis are [SMP](https://www.unicode.org/roadmaps/smp/) characters and will be ignored by ChromeDriver.

  Using JavaScript is a known workaround for filling in fields with Emojis and other non-BMP characters.
  """
  @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 the mouse on the element returned by the query or at the current mouse cursor position.
  """
  @spec click(parent, :left | :middle | :right) :: parent
  @spec click(parent, Query.t()) :: parent
  def click(parent, button) when button in [:left, :middle, :right] do
    case parent.driver.click(parent, button) do
      {:ok, _} ->
        parent
    end
  end

  def click(parent, query) do
    parent
    |> find(query, &Element.click/1)
  end

  @doc """
  Double-clicks left mouse button at the current mouse coordinates.
  """
  @spec double_click(parent) :: parent
  def double_click(parent) do
    case parent.driver.double_click(parent) do
      {:ok, _} ->
        parent
    end
  end

  @doc """
   Clicks and holds the given mouse button at the current mouse coordinates.
  """
  @spec button_down(parent, atom) :: parent
  def button_down(parent, button \\ :left) when button in [:left, :middle, :right] do
    case parent.driver.button_down(parent, button) do
      {:ok, _} ->
        parent
    end
  end

  @doc """
   Releases given previously held mouse button.
  """
  @spec button_up(parent, atom) :: parent
  def button_up(parent, button \\ :left) when button in [:left, :middle, :right] do
    case parent.driver.button_up(parent, button) do
      {:ok, _} ->
        parent
    end
  end

  @doc """
  Hovers over an element.
  """
  @spec hover(parent, Query.t()) :: parent
  def hover(parent, query) do
    parent
    |> find(query, &Element.hover/1)
  end

  @doc """
  Moves mouse by an offset relative to current cursor position.
  """
  @spec move_mouse_by(parent, integer, integer) :: parent
  def move_mouse_by(parent, x_offset, y_offset) do
    case parent.driver.move_mouse_by(parent, x_offset, y_offset) do
      {:ok, _} ->
        parent
    end
  end

  @doc """
  Touches the screen at the given position.
  """
  @spec touch_down(parent, integer, integer) :: session

  def touch_down(parent, x, y) when is_integer(x) and is_integer(y) do
    case parent.driver.touch_down(parent, nil, x, y) do
      {:ok, _} ->
        parent
    end
  end

  @doc """
  Touches and holds the element on its top-left corner plus an optional offset.
  """
  @spec touch_down(parent, Query.t(), integer, integer) :: session

  def touch_down(parent, query, x_offset \\ 0, y_offset \\ 0) do
    parent
    |> find(query, &Element.touch_down(&1, x_offset, y_offset))
  end

  @doc """
  Stops touching the screen.
  """
  @spec touch_up(parent) :: parent

  def touch_up(parent) do
    case parent.driver.touch_up(parent) do
      {:ok, _} ->
        parent
    end
  end

  @doc """
  Taps the element.
  """
  @spec tap(parent, Query.t()) :: session

  def tap(parent, query) do
    parent
    |> find(query, &Element.tap/1)
  end

  @doc """
  Moves the touch pointer (finger, stylus etc.) on the screen to the point determined by the given coordinates.
  """
  @spec touch_move(parent, non_neg_integer, non_neg_integer) :: parent

  def touch_move(parent, x, y) do
    case parent.driver.touch_move(parent, x, y) do
      {:ok, _} ->
        parent
    end
  end

  @doc """
  Scroll on the screen from the given element by the given offset using touch events.
  """
  @spec touch_scroll(parent, Query.t(), integer, integer) :: parent

  def touch_scroll(parent, query, x, y) do
    parent
    |> find(query, &Element.touch_scroll(&1, x, y))
  end

  @doc """
  Gets the Element's text value.

  If the element is not visible, the return value will be `""`.
  """
  @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 and returns one or more DOM element(s) on the page based on the given query.

  The query is scoped by the first argument, which is either the `%Session{}` or an
  `%Element{}`.

  ## Example

  ```elixir
  session
  |> find(Query.css("#login-button"))
  |> assert_text("Login")

  buttons =
    session
    |> find(Query.css(".login-button", count: 2, text: "Login"))
  ```

  ## Notes

  - Blocks until it finds the element(s) or 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 `count: :any` to allow any number of elements to be present.
  - By default only elements that would be visible to a real user on the page are returned.
  """
  @spec find(parent, Query.t()) :: Element.t() | [Element.t()]
  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}

        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} ->
        raise Wallaby.QueryError, ErrorMessage.message(query, e)
    end
  end

  @doc """
  Same as `find/2`, but takes a callback to enact side effects on the found element(s).

  ## Example

  ```elixir
  session
  |> find(Query.css("#login-button"), fn button ->
    assert_text(button, "Login")
  end)

  session
  |> find(Query.css(".login-button", count: 2, text: "Login"), fn buttons ->
    assert 2 == length(buttons)
  end)

  ```

  ## Notes

  - Returns the first argument to make the function pipe-able.
  """
  @spec find(parent, Query.t(), (Element.t() -> any())) :: parent
  def find(parent, %Query{} = query, callback) when is_function(callback) do
    results = find(parent, query)
    callback.(results)

    parent
  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 ->
        if Element.value(element) == value do
          {:ok, true}
        else
          {:error, false}
        end
      end)

    case result do
      {:ok, true} ->
        true

      {:error, false} ->
        false
    end
  end

  @doc """
  Matches the parent's content with the provided text.

  Returns a boolean that indicates if the text was found.

  ## Examples

  ```
  session
  |> visit("/")
  |> has_text?("Login")
  ```

  Example providing query:

  ```
  session
  |> visit("/")
  |> has_text?(Query.css(".login-button"), "Login")
  ```
  """
  @spec has_text?(parent, 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 ->
        if Element.text(parent) =~ text do
          {:ok, true}
        else
          {: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.

  Returns the given `parent` if the assertion is correct so that it is easily
  pipeable.

  ## Examples

  ```
  session
  |> visit("/")
  |> assert_text("Login")
  ```

  Example providing query:

  ```
  session
  |> visit("/")
  |> assert_text(Query.css(".login-button"), "Login")
  ```
  """
  @spec assert_text(parent, String.t()) :: parent
  @spec assert_text(parent, Query.t(), String.t()) :: parent
  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
    if has_text?(parent, text) do
      parent
    else
      raise ExpectationNotMetError, "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)

      case execute_query(parent, query) do
        {:ok, _query_result} ->
          parent

        error ->
          case error do
            {:error, {:not_found, results}} ->
              query = %Query{query | result: results}

              raise ExpectationNotMetError,
                    Query.ErrorMessage.message(query, :not_found)

            {:error, e} ->
              raise Wallaby.QueryError,
                    Query.ErrorMessage.message(query, e)

            _ ->
              raise Wallaby.ExpectationNotMetError,
                    "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, :invalid_selector} ->
          raise Wallaby.QueryError,
                Query.ErrorMessage.message(query, :invalid_selector)

        {:error, _not_found} ->
          parent

        {:ok, query} ->
          raise Wallaby.ExpectationNotMetError,
                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, String.t()) :: 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
    |> has?(Query.css(css, count: :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, String.t()) :: 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 NoBaseUrlError, 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, attributes \\ []) do
    if blank_page?(session) do
      raise CookieError
    end

    case driver.set_cookie(session, key, value, attributes) do
      {:ok, _list} ->
        session

      {:error, :invalid_cookie_domain} ->
        raise CookieError
    end
  end

  defp blank_page?(%Session{driver: driver} = session) do
    driver.blank_page?(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))

    if Enum.count(buttons) == 1 do
      {:error, :button_with_bad_type}
    else
      {: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)

    case labels do
      [label] ->
        for_attr = Element.attr(label, "for")

        error =
          if for_attr == nil do
            :label_with_no_for
          else
            id_query = Query.css("[id='#{for_attr}']", count: :any)
            matching_id_count = parent |> all(id_query) |> Enum.count()

            {:label_does_not_find_field, for_attr, matching_id_count}
          end

        {:error, error}

      _ ->
        {:ok, query}
    end
  end

  defp validate_html(_, query), do: {:ok, query}

  defp validate_visibility(query, elements) do
    case Query.visible?(query) do
      :any ->
        {:ok, elements}

      true ->
        {:ok, Enum.filter(elements, &Element.visible?(&1))}

      false ->
        {:ok, Enum.reject(elements, &Element.visible?(&1))}
    end
  end

  defp validate_selected(query, elements) do
    case Query.selected?(query) do
      :any ->
        {:ok, elements}

      true ->
        {:ok, Enum.filter(elements, &Element.selected?(&1))}

      false ->
        {:ok, Enum.reject(elements, &Element.selected?(&1))}
    end
  end

  defp validate_count(query, elements) do
    if Query.matches_count?(query, Enum.count(elements)) do
      {:ok, elements}
    else
      {:error, {:not_found, elements}}
    end
  end

  defp do_at(query, elements) do
    case {Query.at_number(query), length(elements)} do
      {:all, _} ->
        {:ok, elements}

      {n, count} when n < count ->
        {:ok, [Enum.at(elements, n)]}

      {_, _} ->
        {: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_selected(query, elements),
             {:ok, elements} <- validate_count(query, elements),
             {:ok, elements} <- do_at(query, elements) do
          {:ok, %Query{query | result: elements}}
        end
      rescue
        StaleReferenceError ->
          {: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 = String.trim_trailing(base_url(), "/")
    path = String.trim_leading(path, "/")

    "#{base_url}/#{path}"
  end

  defp base_url do
    Application.get_env(:wallaby, :base_url) || ""
  end

  defp path_for_screenshot(name) do
    "#{screenshot_dir()}/#{name}.png"
  end

  defp write_screenshot!(path, image_data) do
    expanded_path = Path.expand(path)
    :ok = expanded_path |> Path.dirname() |> File.mkdir_p!()

    :ok = File.write!(expanded_path, image_data)

    :ok
  end

  defp screenshot_dir do
    Application.get_env(:wallaby, :screenshot_dir, "#{File.cwd!()}/screenshots")
  end

  @doc false
  def build_file_url(path) do
    "file://" <> (path |> Path.expand() |> URI.encode())
  end

  defp is_selenium?(parent) do
    parent.driver == Wallaby.Selenium
  end
end