lib/playwright/browser.ex

defmodule Playwright.Browser do
  @moduledoc """
  A `Playwright.Browser` instance is createed via:

    - `Playwright.BrowserType.launch/0`, when using the "driver" transport.
    - `Playwright.BrowserType.connect/1`, when using the "websocket" transport.

  An example of using a `Playwright.Browser` to create a `Playwright.Page`:

      alias Playwright.{Browser, Page}

      browser = Playwright.launch(:chromium)
      page = Browser.new_page(browser)

      Page.goto(page, "https://example.com")
      Browser.close(browser)

  ## Properties

    - `:name`
    - `:version`
  """
  use Playwright.ChannelOwner
  alias Playwright.{Browser, BrowserContext, ChannelOwner, Extra, Page}
  alias Playwright.Channel

  @property :name
  @property(:version, %{doc: "Returns the browser version"})

  @typedoc "Supported events"
  @type event :: :disconnected

  @typedoc "A map/struct providing call options"
  @type options :: map()

  # callbacks
  # ---------------------------------------------------------------------------

  @impl ChannelOwner
  def init(browser, _initializer) do
    {:ok, %{browser | version: cut_version(browser.version)}}
  end

  # API
  # ---------------------------------------------------------------------------

  @doc """
  Closes the browser.

  Given a `Playwright.Browser` obtained from `Playwright.BrowserType.launch/2`,
  closes the `Browser` and all of its `Pages` (if any were opened).

  Given a `Playwright.Browser` obtained via `Playwright.BrowserType.connect/2`,
  clears all created `Contexts` belonging to this `Browser` and disconnects
  from the browser server.

  The Browser object itself is considered to be disposed and cannot be used anymore.

  ## Returns

    - `:ok`

  """
  def close(%Browser{session: session} = browser) do
    case Channel.post(session, {:guid, browser.guid}, :close) do
      :ok ->
        :ok

      {:error, %Channel.Error{message: "Target page, context or browser has been closed"}} ->
        :ok
    end
  end

  @doc """
  Returns an array of all open browser contexts. In a newly created browser,
  this will return zero browser contexts.

  ## Example

      contexts = Browser.contexts(browser)
      asset Enum.empty?(contexts)

      Browser.new_context(browser)

      contexts = Browser.contexts(browser)
      assert length(contexts) == 1
  """
  @spec contexts(t()) :: [BrowserContext.t()]
  def contexts(%Browser{} = browser) do
    Channel.list(browser.session, {:guid, browser.guid}, "BrowserContext")
  end

  # ---

  # @spec is_connected(BrowserContext.t()) :: boolean()
  # def is_connected(browser)

  # @spec new_browser_cdp_session(BrowserContext.t()) :: Playwright.CDPSession.t()
  # def new_browser_cdp_session(browser)

  # ---

  @doc """
  Create a new `Playwright.BrowserContext` for this `Playwright.Browser`.

  A `BrowserContext` does not share cookies/cache with other `BrowserContexts`
  and is somewhat equivalent to an "incognito" browser "window".

  ## Example

      # create a new "incognito" browser context.
      context = Browser.new_context(browser)

      # create a new page in a pristine context.
      page = BrowserContext.new_page(context)

      Page.goto(page, "https://example.com")

  ## Returns

    - `Playwright.BrowserContext.t()`

  ## Arguments

  | key/name         | type   |             | description |
  | ------------------ | ------ | ----------- | ----------- |
  | `accept_downloads` | option | `boolean()` | Whether to automatically download all the attachments. If false, all the downloads are canceled. `(default: false)` |
  | `...`              | option | `...`       | ... |
  """
  @spec new_context(t(), options()) :: BrowserContext.t()
  def new_context(%Browser{guid: guid} = browser, options \\ %{}) do
    Channel.post(browser.session, {:guid, guid}, :new_context, prepare(options))
  end

  @doc """
  Create a new `Playwright.Page` for this Browser, within a new "owned"
  `Playwright.BrowserContext`.

  That is, `Playwright.Browser.new_page/2` will also create a new
  `Playwright.BrowserContext`. That `BrowserContext` becomes, both, the
  *parent* of the `Page`, and *owned by* the `Page`. When the `Page` closes,
  the context goes with it.

  This is a convenience API function that should only be used for single-page
  scenarios and short snippets. Production code and testing frameworks should
  explicitly create via `Playwright.Browser.new_context/2` followed by
  `Playwright.BrowserContext.new_page/2`, given the new context, to manage
  resource lifecycles.
  """
  @spec new_page(t(), options()) :: Page.t()
  def new_page(browser, options \\ %{})

  def new_page(%Browser{session: session} = browser, options) do
    context = new_context(browser, options)
    page = BrowserContext.new_page(context)

    # establish co-dependency
    Channel.patch(session, {:guid, context.guid}, %{owner_page: page})
    Channel.patch(session, {:guid, page.guid}, %{owned_context: context})
  end

  # ---

  # test_browsertype_connect.py
  # @spec on(t(), event(), function()) :: Browser.t()
  # def on(browser, event, callback)

  # test_chromium_tracing.py
  # @spec start_tracing(t(), Page.t(), options()) :: :ok
  # def start_tracing(browser, page \\ nil, options \\ %{})

  # test_chromium_tracing.py
  # @spec stop_tracing(t()) :: binary()
  # def stop_tracing(browser)

  # ---

  # private
  # ----------------------------------------------------------------------------

  # Chromium version is \d+.\d+.\d+.\d+, but that doesn't parse well with
  # `Version`. So, until it causes issue we're cutting it down to
  # <major.minor.patch>.
  defp cut_version(version) do
    version |> String.split(".") |> Enum.take(3) |> Enum.join(".")
  end

  defp prepare(%{extra_http_headers: headers}) do
    %{
      extraHTTPHeaders:
        Enum.reduce(headers, [], fn {k, v}, acc ->
          [%{name: k, value: v} | acc]
        end)
    }
  end

  defp prepare(opts) when is_map(opts) do
    Enum.reduce(opts, %{}, fn {k, v}, acc -> Map.put(acc, prepare(k), v) end)
  end

  defp prepare(string) when is_binary(string) do
    string
  end

  defp prepare(atom) when is_atom(atom) do
    Extra.Atom.to_string(atom)
    |> Recase.to_camel()
    |> Extra.Atom.from_string()
  end
end