lib/wallaby/selenium.ex

defmodule Wallaby.Selenium do
  @moduledoc """
  The Selenium driver uses [Selenium Server](https://github.com/SeleniumHQ/selenium) to power many types of browsers (Chrome, Firefox, Edge, etc).

  ## Usage

  Start a Wallaby Session using this driver with the following command:

  ```elixir
  {:ok, session} = Wallaby.start_session()
  ```

  ## Configuration

  ### Capabilities

  These capabilities will override the default capabilities.

  ```elixir
  config :wallaby,
    selenium: [
      capabilities: %{
        # something
      }
    ]
  ```

  ### Selenium Remote URL

  It is possible to globally set Selenium's "Remote URL" by setting the following option.

  By default it is http://localhost:4444/wd/hub/

  ```elixir
  config :wallaby,
    selenium: [
      remote_url: "http://selenium_url"
    ]
  ```

  ## Default Capabilities

  By default, Selenium 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.

  ```elixir
  %{
    javascriptEnabled: true,
    browserName: "firefox",
    "moz:firefoxOptions": %{
      args: ["-headless"]
    }
  }
  ```

  ## Notes

  - Requires [selenium-server](https://www.seleniumhq.org/download/) to be running on port 4444. Wallaby does _not_ manage the start/stop of the Selenium server.
  - Requires [GeckoDriver](https://github.com/mozilla/geckodriver) to be installed in your path when using [Firefox](https://www.mozilla.org/en-US/firefox/new/). Firefox is used by default.
  """

  use Supervisor

  @behaviour Wallaby.Driver

  alias Wallaby.Helpers.KeyCodes
  alias Wallaby.Metadata
  alias Wallaby.WebdriverClient
  alias Wallaby.{Driver, Element, Session}

  @typedoc """
  Options to pass to Wallaby.start_session/1

  ```elixir
  Wallaby.start_session(
    remote_url: "http://selenium_url",
    capabilities: %{browserName: "firefox"}
  )
  ```
  """
  @type start_session_opts ::
          {:remote_url, String.t()}
          | {:capabilities, map}

  @doc false
  def start_link(opts \\ []) do
    Supervisor.start_link(__MODULE__, :ok, opts)
  end

  @doc false
  def init(_) do
    Supervisor.init([], strategy: :one_for_one)
  end

  @doc false
  def validate do
    :ok
  end

  @doc false
  @spec start_session([start_session_opts]) :: Wallaby.Driver.on_start_session() | no_return
  def start_session(opts \\ []) do
    base_url = Keyword.get(opts, :remote_url, remote_url_from_config())
    create_session = Keyword.get(opts, :create_session_fn, &WebdriverClient.create_session/2)

    capabilities =
      opts
      |> Keyword.get_lazy(:capabilities, &capabilities_from_config/0)
      |> put_beam_metadata(opts)

    with {:ok, response} <- create_session.(base_url, capabilities) do
      id = response["sessionId"]

      session = %Session{
        session_url: base_url <> "session/#{id}",
        url: base_url <> "session/#{id}",
        id: id,
        driver: __MODULE__,
        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 do
    :wallaby
    |> Application.get_env(:selenium, [])
    |> Keyword.get_lazy(:capabilities, &default_capabilities/0)
  end

  defp remote_url_from_config() do
    :wallaby
    |> Application.get_env(:selenium, [])
    |> Keyword.get(:remote_url, "http://localhost:4444/wd/hub/")
  end

  @doc false
  @spec end_session(Session.t()) :: :ok
  def end_session(session) do
    WebdriverClient.delete_session(session)
    :ok
  end

  @doc false
  def blank_page?(session) do
    case current_url(session) do
      {:ok, url} ->
        url == "about:blank"

      _ ->
        false
    end
  end

  @doc false
  defdelegate window_handle(session), to: WebdriverClient
  @doc false
  defdelegate window_handles(session), to: WebdriverClient
  @doc false
  defdelegate focus_window(session, window_handle), to: WebdriverClient
  @doc false
  defdelegate close_window(session), to: WebdriverClient
  @doc false
  defdelegate get_window_size(session), to: WebdriverClient
  @doc false
  defdelegate set_window_size(session, width, height), to: WebdriverClient
  @doc false
  defdelegate get_window_position(session), to: WebdriverClient
  @doc false
  defdelegate set_window_position(session, x, y), to: WebdriverClient
  @doc false
  defdelegate maximize_window(session), to: WebdriverClient

  @doc false
  defdelegate focus_frame(session, frame), to: WebdriverClient
  @doc false
  defdelegate focus_parent_frame(session), to: WebdriverClient

  @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 take_screenshot(session_or_element), to: WebdriverClient

  @doc false
  def cookies(%Session{} = session) do
    WebdriverClient.cookies(session)
  end

  @doc false
  def current_path(%Session{} = session) do
    with {:ok, url} <- WebdriverClient.current_url(session) do
      url
      |> URI.parse()
      |> Map.fetch(:path)
    end
  end

  @doc false
  def current_url(%Session{} = session) do
    WebdriverClient.current_url(session)
  end

  @doc false
  def page_source(%Session{} = session) do
    WebdriverClient.page_source(session)
  end

  @doc false
  def page_title(%Session{} = session) do
    WebdriverClient.page_title(session)
  end

  @doc false
  def set_cookie(%Session{} = session, key, value, attributes \\ []) do
    WebdriverClient.set_cookie(session, key, value, attributes)
  end

  @doc false
  def visit(%Session{} = session, path) do
    WebdriverClient.visit(session, path)
  end

  @doc false
  def attribute(%Element{} = element, name) do
    WebdriverClient.attribute(element, name)
  end

  @doc false
  @spec clear(Element.t()) :: {:ok, nil} | {:error, Driver.reason()}
  def clear(%Element{} = element) do
    WebdriverClient.clear(element)
  end

  @doc false
  def click(%Element{} = element) do
    WebdriverClient.click(element)
  end

  @doc false
  def click(parent, button) do
    WebdriverClient.click(parent, button)
  end

  @doc false
  def button_down(parent, button) do
    WebdriverClient.button_down(parent, button)
  end

  @doc false
  def button_up(parent, button) do
    WebdriverClient.button_up(parent, button)
  end

  @doc false
  def double_click(parent) do
    WebdriverClient.double_click(parent)
  end

  @doc false
  def hover(%Element{} = element) do
    WebdriverClient.move_mouse_to(nil, element)
  end

  @doc false
  def move_mouse_by(session, x_offset, y_offset) do
    WebdriverClient.move_mouse_to(session, nil, x_offset, y_offset)
  end

  @doc false
  def displayed(%Element{} = element) do
    WebdriverClient.displayed(element)
  end

  @doc false
  def selected(%Element{} = element) do
    WebdriverClient.selected(element)
  end

  @doc false
  @spec set_value(Element.t(), String.t()) :: {:ok, nil} | {:error, Driver.reason()}
  def set_value(%Element{} = element, value) do
    WebdriverClient.set_value(element, value)
  end

  @doc false
  def text(%Element{} = element) do
    WebdriverClient.text(element)
  end

  @doc false
  def find_elements(parent, compiled_query) do
    WebdriverClient.find_elements(parent, compiled_query)
  end

  @doc false
  def execute_script(parent, script, arguments \\ []) do
    WebdriverClient.execute_script(parent, script, arguments)
  end

  @doc false
  def execute_script_async(parent, script, arguments \\ []) do
    WebdriverClient.execute_script_async(parent, script, arguments)
  end

  @doc """
  Simulates typing into an element.

  When sending keys to an element and `keys` is identified as
  a local file, the local file is uploaded to the
  Selenium server, returning a file path which is then
  set to the file input we are interacting with.

  We then call the `WebdriverClient.send_keys/2` to set the
  remote file path as the input's value.
  """
  @spec send_keys(Session.t() | Element.t(), list()) :: {:ok, any}
  def send_keys(%Session{} = session, keys), do: WebdriverClient.send_keys(session, keys)

  def send_keys(%Element{} = element, keys) do
    keys =
      case Enum.all?(keys, &is_local_file?(&1)) do
        true ->
          keys
          |> Enum.map(fn key -> upload_file(element, key) end)
          |> Enum.intersperse("\n")

        false ->
          keys
      end

    WebdriverClient.send_keys(element, keys)
  end

  def element_size(element) do
    WebdriverClient.element_size(element)
  end

  def element_location(element) do
    WebdriverClient.element_location(element)
  end

  @doc false
  def default_capabilities do
    %{
      browserName: "firefox",
      "moz:firefoxOptions": %{
        args: ["-headless"],
        prefs: %{
          "general.useragent.override" =>
            "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"
        }
      }
    }
  end

  # Create a zip file containing our local file
  defp create_zipfile(zipfile, filename) do
    {:ok, ^zipfile} =
      :zip.create(
        zipfile,
        [String.to_charlist(Path.basename(filename))],
        cwd: String.to_charlist(Path.dirname(filename))
      )

    zipfile
  end

  @binread_arg if Version.parse!(System.version()).minor >= 13, do: :eof, else: :all

  # Base64 encode the zipfile for transfer to remote Selenium
  defp encode_zipfile(zipfile) do
    File.open!(zipfile, [:read, :raw], fn f ->
      f
      |> IO.binread(@binread_arg)
      |> Base.encode64()
    end)
  end

  defp is_local_file?(file) do
    file
    |> keys_to_binary()
    |> File.exists?()
  end

  defp keys_to_binary(keys) do
    keys
    |> KeyCodes.chars()
    |> IO.iodata_to_binary()
  end

  # Makes an uploadable file for JSONWireProtocol
  defp make_file(filename) do
    System.tmp_dir!()
    |> Path.join("#{random_filename()}.zip")
    |> String.to_charlist()
    |> create_zipfile(filename)
    |> encode_zipfile()
  end

  # Generate a random filename
  defp random_filename do
    Base.encode32(:crypto.strong_rand_bytes(20))
  end

  # Uploads a local file to remote Selenium server
  # Returns the remote file's uploaded location
  defp upload_file(element, filename) do
    zip64 = make_file(filename)
    endpoint = element.session_url <> "/file"

    with {:ok, response} <- Wallaby.HTTPClient.request(:post, endpoint, %{file: zip64}) do
      Map.fetch!(response, "value")
    end
  end

  defp put_beam_metadata(%{"moz:firefoxOptions": %{prefs: %{}}} = capabilities, opts) do
    capabilities
    |> update_in(
      [:"moz:firefoxOptions", :prefs, "general.useragent.override"],
      fn user_agent ->
        if user_agent do
          Metadata.append(user_agent, opts[:metadata])
        end
      end
    )
  end

  defp put_beam_metadata(capabilities, _opts), do: capabilities
end