defmodule Wallaby.WebdriverClient do @moduledoc false # Client implementation for the WebDriver Wire Protocol # documented on https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol alias Wallaby.{Driver, Element, Query, Session} alias Wallaby.Helpers.KeyCodes import Wallaby.HTTPClient @type http_method :: :post | :get | :delete @type url :: String.t() @type parent :: Element.t() | Session.t() @web_element_identifier "element-6066-11e4-a52e-4f735466cecf" @button_mapping %{left: 0, middle: 1, right: 2} @doc """ Create a session with the base url. """ @spec create_session(String.t(), map) :: {:ok, map} def create_session(base_url, capabilities) do params = %{desiredCapabilities: capabilities} request(:post, "#{base_url}session", params) end @doc """ Deletes a session with the driver. """ @spec delete_session(Session.t() | Element.t()) :: {:ok, map} def delete_session(session) do request(:delete, session.session_url, %{}) rescue _ -> {:ok, %{}} end @doc """ Finds an element on the page for a session. If an element is provided then the query will be scoped to within that element. """ @spec find_elements(Session.t() | Element.t(), Query.compiled()) :: {:ok, [Element.t()]} def find_elements(parent, locator) do with {:ok, resp} <- request(:post, parent.url <> "/elements", to_params(locator)), {:ok, elements} <- Map.fetch(resp, "value"), elements <- Enum.map(elements || [], &cast_as_element(parent, &1)), do: {:ok, elements} end @doc """ Sets the value of an element. """ @spec set_value(Element.t(), String.t()) :: {:ok, nil} | {:error, Driver.reason()} def set_value(%Element{url: url}, value) do case request(:post, "#{url}/value", %{value: [value]}) do {:ok, resp} -> {:ok, Map.get(resp, "value")} {:error, reason} -> {:error, reason} end end @doc """ Clears the value in an element """ @spec clear(Element.t()) :: {:ok, nil} | {:error, Driver.reason()} def clear(%Element{url: url}) do case request(:post, "#{url}/clear") do {:ok, resp} -> {:ok, Map.get(resp, "value")} {:error, reason} -> {:error, reason} end end @doc """ Invoked to accept one alert triggered within `open_dialog_fn` and return the alert message. """ def accept_alert(session, fun) do fun.(session) with {:ok, value} <- alert_text(session), {:ok, _r} <- request(:post, "#{session.url}/accept_alert"), do: value end @doc """ Invoked to accept one alert triggered within `open_dialog_fn` and return the alert message. """ def dismiss_alert(session, fun) do accept_alert(session, fun) end def accept_confirm(session, fun) do accept_alert(session, fun) end def dismiss_confirm(session, fun) do fun.(session) with {:ok, value} <- alert_text(session), {:ok, _r} <- request(:post, "#{session.url}/dismiss_alert") do value end end def accept_prompt(session, input, fun) when is_nil(input) do fun.(session) with {:ok, value} <- alert_text(session), {:ok, _r} <- request(:post, "#{session.url}/accept_alert") do value end end def accept_prompt(session, input, fun) do fun.(session) with {:ok, _r} <- request(:post, "#{session.url}/alert_text", %{text: input}), {:ok, value} <- alert_text(session), {:ok, _r} <- request(:post, "#{session.url}/accept_alert") do value end end def dismiss_prompt(session, fun) do dismiss_confirm(session, fun) end @doc """ Clicks an element. """ @spec click(Element.t()) :: {:ok, map} def click(%Element{url: url}) do with {:ok, resp} <- request(:post, "#{url}/click") do Map.fetch(resp, "value") end end @doc """ Clicks given mouse button on the current cursor position. """ # Doesn't work for Firefox with middle and right mouse button @spec click(parent, atom) :: {:ok, map} def click(parent, button) when button in [:left, :middle, :right] do with {:ok, resp} <- request(:post, "#{parent.session_url}/click", %{button: @button_mapping[button]}) do Map.fetch(resp, "value") end end @doc """ Double-clicks left mouse button at the current mouse coordinates. """ @spec double_click(parent) :: {:ok, map} def double_click(parent) do with {:ok, resp} <- request(:post, "#{parent.session_url}/doubleclick") do Map.fetch(resp, "value") end end @doc """ Clicks and holds the given mouse button at the current mouse coordinates. """ # Doesn't work for Firefox with middle and right mouse button. @spec button_down(parent, atom) :: {:ok, map} def button_down(parent, button) when button in [:left, :middle, :right] do with {:ok, resp} <- request(:post, "#{parent.session_url}/buttondown", %{button: @button_mapping[button]}) do Map.fetch(resp, "value") end end @doc """ Releases given previously held mouse button. """ # Doesn't work for Firefox with middle and right mouse button. @spec button_up(parent, atom) :: {:ok, map} def button_up(parent, button) when button in [:left, :middle, :right] do with {:ok, resp} <- request(:post, "#{parent.session_url}/buttonup", %{button: @button_mapping[button]}) do Map.fetch(resp, "value") end end @doc """ Move the mouse by an offset of the specified element. If no element is specified, the move is relative to the current mouse cursor. If an element is provided but no offset, the mouse will be moved to the center of the element. Gets keyword list with element, xoffset and yoffset specified as an argument. """ @spec move_mouse_to(parent | nil, Element.t() | nil, integer | nil, integer | nil) :: {:ok, map} def move_mouse_to(session, element, x_offset \\ nil, y_offset \\ nil) do params = %{element: element, xoffset: x_offset, yoffset: y_offset} |> Enum.filter(fn {_key, value} -> not is_nil(value) end) |> Enum.into(%{}) params = if Map.has_key?(params, :element) do Map.put(params, :element, params[:element].id) else params end session_url = if is_nil(element) do session.session_url else element.session_url end with {:ok, resp} <- request(:post, "#{session_url}/moveto", params) do Map.fetch(resp, "value") end end @doc """ Touches the screen at the given position. """ @spec touch_down(parent | nil, Element.t() | nil, integer, integer) :: {:ok, map} def touch_down(session, element, x_or_offset \\ 0, y_or_offset \\ 0) do session_url = if is_nil(element) do session.session_url else element.session_url end {x, y} = if is_nil(element) do {x_or_offset, y_or_offset} else {:ok, {x, y}} = element_location(element) {x + x_or_offset, y + y_or_offset} end with {:ok, resp} <- request(:post, "#{session_url}/touch/down", %{x: x, y: y}) do Map.fetch(resp, "value") end end @doc """ Stops touching the screen. """ @spec touch_up(parent) :: {:ok, map} def touch_up(parent) do # in fact x and y (in request params) can be any coordinates not greater than page size with {:ok, resp} <- request(:post, "#{parent.session_url}/touch/up", %{x: 0, y: 0}) do Map.fetch(resp, "value") end end @doc """ Taps the element. """ @spec tap(Element.t()) :: {:ok, map} def tap(element) do with {:ok, resp} <- request(:post, "#{element.session_url}/touch/click", %{element: element.id}) do Map.fetch(resp, "value") end 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) :: {:ok, map} def touch_move(parent, x, y) do with {:ok, resp} <- request(:post, "#{parent.session_url}/touch/move", %{x: x, y: y}) do Map.fetch(resp, "value") end end @doc """ Scroll on the screen from the given element by the given offset using touch events. """ @spec touch_scroll(Element.t(), integer, integer) :: {:ok, map} def touch_scroll(element, x_offset, y_offset) do params = %{ element: element.id, xoffset: x_offset, yoffset: y_offset } with {:ok, resp} <- request(:post, "#{element.session_url}/touch/scroll", params) do Map.fetch(resp, "value") end end @doc """ Gets the text for an element. """ @spec text(Element.t()) :: {:ok, String.t()} def text(element) do with {:ok, resp} <- request(:get, "#{element.url}/text") do Map.fetch(resp, "value") end end @doc """ Gets the title of the current page. """ @spec page_title(Session.t()) :: {:ok, String.t()} def page_title(session) do with {:ok, resp} <- request(:get, "#{session.url}/title") do Map.fetch(resp, "value") end end @doc """ Gets the value of an elements attribute. """ @spec attribute(Element.t(), String.t()) :: {:ok, String.t()} def attribute(element, name) do with {:ok, resp} <- request(:get, "#{element.url}/attribute/#{name}") do Map.fetch(resp, "value") end end @doc """ Visit a specific page. """ @spec visit(Session.t(), String.t()) :: :ok def visit(session, path) do with {:ok, resp} <- request(:post, "#{session.url}/url", %{url: path}), {:ok, _} <- Map.fetch(resp, "value") do :ok end end @doc """ Gets the current url. """ @spec current_url(Session.t()) :: {:ok, String.t()} | {:error, any()} def current_url(session) do with {:ok, resp} <- request(:get, "#{session.url}/url") do Map.fetch(resp, "value") end end @doc """ Gets the current path or nil. """ @spec current_path(Session.t()) :: {:ok, String.t()} | {:error, any()} def current_path(session) do with {:ok, url} <- current_url(session) do url |> URI.parse() |> Map.fetch(:path) end end @doc """ Gets the selected value of the element. For Checkboxes and Radio buttons it returns the selected option. For options selects it returns the selected option """ @spec selected(Element.t()) :: {:ok, boolean} | {:error, :stale_reference} def selected(element) do with {:ok, resp} <- request(:get, "#{element.url}/selected") do Map.fetch(resp, "value") end end @doc """ Checks if the element is being displayed. """ @spec displayed(Element.t()) :: {:ok, boolean} | {:error, :stale_reference} def displayed(element) do with {:ok, resp} <- request(:get, "#{element.url}/displayed") do Map.fetch(resp, "value") end end @doc """ Gets the size of a element. """ @spec size(Element.t()) :: {:ok, any} def size(element) do with {:ok, resp} <- request(:get, "#{element.url}/size") do Map.fetch(resp, "value") end end @doc """ Gets the height, width, x, and y position of an Element. """ @spec rect(Element.t()) :: {:ok, any} def rect(element) do with {:ok, resp} <- request(:get, "#{element.url}/rect") do Map.fetch(resp, "value") end end @doc """ Takes a screenshot. """ @spec take_screenshot(Session.t() | Element.t()) :: binary def take_screenshot(session) do with {:ok, resp} <- request(:get, "#{session.session_url}/screenshot"), {:ok, value} <- Map.fetch(resp, "value") do :base64.decode(value) end end @doc """ Gets the cookies for a session. """ @spec cookies(Session.t()) :: {:ok, [map]} def cookies(session) do with {:ok, resp} <- request(:get, "#{session.url}/cookie") do Map.fetch(resp, "value") end end @doc """ Sets a cookie for the session. It accepts a list of optional cookie attributes per the [WebDriver specification](https://www.w3.org/TR/webdriver1/#cookies): * path (string) * domain (string) * secure (boolean) * httpOnly (boolean) * expiry (integer; number of seconds since Unix Epoch) """ @spec set_cookie(Session.t(), String.t(), String.t()) :: {:ok, []} @spec set_cookie(Session.t(), String.t(), String.t(), keyword()) :: {:ok, []} def set_cookie(session, key, value, attributes \\ []) do cookie_params = attributes |> Enum.into(%{}) |> Map.take(~w[path domain secure httpOnly expiry]a) |> Map.merge(%{name: key, value: value}) with {:ok, resp} <- request(:post, "#{session.url}/cookie", %{cookie: cookie_params}) do Map.fetch(resp, "value") end end @doc """ Sets the size of the window """ @spec set_window_size(parent, non_neg_integer, non_neg_integer) :: {:ok, map} def set_window_size(session, width, height) do params = %{width: width, height: height} with {:ok, resp} <- request(:post, "#{session.url}/window/current/size", params) do Map.fetch(resp, "value") end end @doc """ Gets the size of the window """ @spec get_window_size(parent) :: {:ok, map} def get_window_size(session) do with {:ok, resp} <- request(:get, "#{session.url}/window/current/size") do Map.fetch(resp, "value") end end @doc """ Changes the position of the window """ @spec set_window_position(parent, non_neg_integer, non_neg_integer) :: {:ok, map} def set_window_position(session, x_coordinate, y_coordinate) do params = %{ x: x_coordinate, y: y_coordinate } with {:ok, resp} <- request(:post, "#{session.url}/window/current/position", params) do Map.fetch(resp, "value") end end @doc """ Gets the position of the window """ @spec get_window_position(parent) :: {:ok, map} def get_window_position(session) do with {:ok, resp} <- request(:get, "#{session.url}/window/current/position") do Map.fetch(resp, "value") end end @doc """ Maximizes the window if not already maximized """ @spec maximize_window(parent) :: {:ok, map} def maximize_window(session) do with {:ok, resp} <- request(:post, "#{session.url}/window/current/maximize") do Map.fetch(resp, "value") end end @doc """ Executes JavaScript synchronously, taking as arguments the script to execute, and optionally a list of arguments available in the script via `arguments` """ @spec execute_script(Session.t() | Element.t(), String.t(), Keyword.t()) :: {:ok, any} def execute_script(session, script, arguments \\ []) do params = %{script: script, args: arguments} with {:ok, resp} <- request(:post, "#{session.session_url}/execute", params) do Map.fetch(resp, "value") end end @doc """ Executes asynchronous JavaScript, taking as arguments the script to execute, and optionally a list of arguments available in the script via `arguments` """ @spec execute_script_async(Session.t() | Element.t(), String.t(), Keyword.t()) :: {:ok, any} def execute_script_async(session, script, arguments \\ []) do params = %{ script: script, args: arguments } with {:ok, resp} <- request(:post, "#{session.session_url}/execute_async", params) do Map.fetch(resp, "value") end end @doc """ Sends a list of key strokes to active element. """ @spec send_keys(parent(), [String.t() | atom]) :: {:ok, any} def send_keys(%Session{} = session, keys) when is_list(keys) do params = KeyCodes.json(keys) opts = [encode_json: false] with {:ok, resp} <- request(:post, "#{session.session_url}/keys", params, opts) do Map.fetch(resp, "value") end end def send_keys(%Element{} = element, keys) when is_list(keys) do params = KeyCodes.json(keys) opts = [encode_json: false] with {:ok, resp} <- request(:post, "#{element.url}/value", params, opts) do Map.fetch(resp, "value") end end @doc """ Retrieves logs from the browser """ @spec log(Session.t() | Element.t()) :: {:ok, [map]} def log(session) do with {:ok, resp} <- request(:post, "#{session.session_url}/log", %{type: "browser"}) do Map.fetch(resp, "value") end end @doc """ Retrieves the current page source from session """ @spec page_source(Session.t()) :: {:ok, String.t()} def page_source(session) do with {:ok, resp} <- request(:get, "#{session.url}/source") do Map.fetch(resp, "value") end end @doc """ Retrieves the list of window handles of all windows (or tabs) available to the session """ @spec window_handles(parent) :: {:ok, list(String.t())} def window_handles(session) do with {:ok, resp} <- request(:get, "#{session.url}/window_handles") do Map.fetch(resp, "value") end end @doc """ Retrieves the window handle for the currently focused window (or tab) for the session """ @spec window_handle(parent) :: {:ok, String.t()} def window_handle(session) do with {:ok, resp} <- request(:get, "#{session.url}/window_handle") do Map.fetch(resp, "value") end end @doc """ Changes focus to another window (or tab) You may specify the the window by its server-assigned window handle or by the value of its name attribute. """ @spec focus_window(parent, String.t()) :: {:ok, map} def focus_window(session, window_handle_or_name) do with {:ok, resp} <- request(:post, "#{session.url}/window", %{ name: window_handle_or_name, handle: window_handle_or_name }) do # In the Selenium WebDriver Protocol, the parameter is called name: # https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#sessionsessionidwindow # In the new W3C protocol, the parameter is called handle: # https://w3c.github.io/webdriver/#switch-to-window # Browsers are starting to support only the new W3C protocol, # so we're adding `handle` as well to have it working until # we implement a separate client module for the new protocol. Map.fetch(resp, "value") end end @doc """ Closes the currently focused window (or tab) """ @spec close_window(parent) :: {:ok, map} def close_window(session) do with {:ok, resp} <- request(:delete, "#{session.url}/window") do Map.fetch(resp, "value") end end @doc """ Changes focus to another frame You may specify the the frame by passing the frame Element, its index or id. When passed `nil`, the server should switch to the page's default (top level) frame. """ @spec focus_frame(parent, String.t() | number | nil | Element.t()) :: {:ok, map} def focus_frame(session, %Element{} = frame_element) do params = %{ id: %{ "ELEMENT" => frame_element.id, @web_element_identifier => frame_element.id } } with {:ok, resp} <- request(:post, "#{session.url}/frame", params) do Map.fetch(resp, "value") end end def focus_frame(session, frame) do with {:ok, resp} <- request(:post, "#{session.url}/frame", %{id: frame}) do Map.fetch(resp, "value") end end @doc """ Changes focus to parent frame """ @spec focus_parent_frame(parent) :: {:ok, map} def focus_parent_frame(session) do with {:ok, resp} <- request(:post, "#{session.url}/frame/parent") do Map.fetch(resp, "value") end end @doc """ Returns a tuple `{width, height}` with the size of the given element. """ @spec element_size(Element.t()) :: {:ok, tuple} def element_size(element) do with {:ok, resp} <- request(:get, "#{element.session_url}/element/#{element.id}/size"), {:ok, value} <- Map.fetch(resp, "value"), do: {:ok, {value["width"], value["height"]}} end @doc """ Returns a tuple `{x, y}` with the coordinates of the top-left corner of given element. """ @spec element_location(Element.t()) :: {:ok, tuple} def element_location(element) do with {:ok, resp} <- request(:get, "#{element.session_url}/element/#{element.id}/location"), {:ok, value} <- Map.fetch(resp, "value"), do: {:ok, {value["x"], value["y"]}} end @spec cast_as_element(Session.t() | Element.t(), map) :: Element.t() defp cast_as_element(parent, %{"ELEMENT" => id}) do # In the Selenium WebDriver Protocol, the identifier is "ELEMENT": # https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#webelement-json-object # In the new W3C protocol, the identifier is a specific constant: # https://w3c.github.io/webdriver/#elements # Browsers are starting to support only the new W3C protocol, # so we're reading the constant as well to have it working until # we implement a separate client module for the new protocol. cast_as_element(parent, %{@web_element_identifier => id}) end defp cast_as_element(parent, %{@web_element_identifier => id}) do %Wallaby.Element{ id: id, session_url: parent.session_url, url: parent.session_url <> "/element/#{id}", parent: parent, driver: parent.driver } end # Retrieves the text from an alert, prompt or confirm. @spec alert_text(Session.t()) :: {:ok, String.t()} defp alert_text(session) do with {:ok, resp} <- request(:get, "#{session.url}/alert_text") do Map.fetch(resp, "value") end end end