lib/playwright/browser_context.ex

defmodule Playwright.BrowserContext do
  @moduledoc """
  `Playwright.BrowserContext` provides a way to operate multiple independent
  browser sessions.

  If a page opens another page, e.g. with a `window.open` call, the popup will
  belong to the parent page's browser context.

  Playwright allows creation of "incognito" browser contexts with the
  `Playwright.Browser.new_context/1` function.

  ## Example

      # create a new "incognito" browser context:
      {:ok, context} = Playwright.Browser.new_context(browser)

      # create and use a new page within that context:
      {:ok, page} = Playwright.BrowserContext.new_page(context)
      {:ok, resp} = Playwright.Page.goto(page, "https://example.com")

      # dispose the context once it's no longer needed:
      Playwright.BrowserContext.close(context)

  ## Regarding `expect_event/5` and `on/3`

  The first argument given to `on/3` and `expect_event/5` functions is the
  "owner" on which to bind the event.

  The second argument is the event type.

  The third argument is a callback function that will be executed when the
  event fires, and is passed an instance of `Playwright.Runner.EventInfo`.

  ### Details for `expect_event/5`

  Calls to `expect_event/5` are blocking. These functions take a "trigger",
  execution of which is expected to result in the event being fired.

  If the event does not fire within the timeout window, the call to
  `expect_event/5` will timeout.

  An optional "predicate" function may be provided, in which case the fired
  event will be sent to the predicate, which must return a "truthy" result
  in order for the expectation to be fulfilled.

      {:ok, e } = BrowserContext.expect_event(context, :close, fn ->
        Page.close(page)
      end)
      assert %BrowserContext{} = e.target

  ### Details for `on/3`

  Calls to `on/3` are non-blocking and register callbacks for the lifetime
  of the binding target.

      BrowserContext.on(context, :close, fn e ->
        assert %BrowserContext{} = e.target
      end)

  ### Event types

  The `expect_event/5` and `on/3` functions support the following event types:

    - `:background_page`

      Emitted when a new background page is created in the context. The event
      target is a `Playwright.Page`.

          ...

      > NOTE:
      > - Only works with Chromium browser's persistent context.

    - `:close`

      Emitted when the `Playwright.BrowserContext` is closed. The event target
      is a `Playwright.BrowserContext`. This might happen because of any of the
      following:

        - Browser context is closed.
        - Browser application is closed or crashed.
        - `Playwright.Browser.close/1` is called.
        - `Playwright.Page.close/1` is with the "owner page" for this context.

    - `:page`

      Emitted when a new `Playwright.Page` is created within the context.
      The page may still be loading. The event target is a `Playwright.Page`.

      The event will also fire for popup pages.

      The earliest moment that a page is available is when it has navigated to
      the initial URL. For example, when opening a popup with
      `window.open('http://example.com')`, this event will fire when the
      network request to "http://example.com" is done and its response has
      started loading in the popup.

          BrowserContext.expect_event(context, :page, fn ->
            Page.click(page, "a[target=_blank]")
          end)

      > NOTE:
      > - Use `Playwright.Page.wait_for_load_state/3` to wait until the page
      >   gets to a particular state (you should not need it in most cases).

    - `:request`

      Emitted when a request is issued from any pages created through this
      context. The event target is a `Playwright.Request`.

      To only listen for requests from a particular page, use
      `Playwright.Page.on/3` (for `:request`).

      In order to intercept and mutate requests, see `route/4` or
      `Playwright.Page.route/4`.

    - `:request_failed`

      Emitted when a request fails, for example by timing out. The event
      target is a `Playwright.Request`.

      To only listen for failed requests from a particular page, use
      `Playwright.Page.on/3` (for `:request_failed`).

      > NOTE:
      > - HTTP error responses, such as 404 or 503, are still successful
      >   responses from HTTP standpoint. So, the request will complete with
      >   a `:request_finished` event and not with `:request_failed`.

    - `:request_finished`

      Emitted when a request finishes successfully after downloading the
      response body. The event target is a `Playwright.Request`.

      For a successful response, the sequence of events is `:request`,
      `:response` and `:request_finished`. To listen for successful requests
      from a particular page, use `Playwright.Page.on/3` (for
      `:request_finished`).

    - `:response`

      Emitted when response status and headers are received for a request.
      The event target is a `Playwright.Response`.

      For a successful response, the sequence of events is `:request`,
      `:response` and `:request_finished`. To listen for response events
      from a particular page, use `Playwright.Page.on/3` (for  `:response`).

    - `:service_worker`

      Emitted when new service worker is created in the context. The event
      target is a `Playwright.Worker`.

      > NOTE:
      > - Service workers are only supported on Chromium-based browsers.
  """

  # ---

  use Playwright.ChannelOwner,
    fields: [:browser, :owner_page]

  alias Playwright.{BrowserContext, ChannelOwner, Page}
  alias Playwright.Runner.Channel

  @typedoc "Recognized cookie fields"
  @type cookie :: %{
          name: String.t(),
          value: String.t(),
          url: String.t(),
          domain: String.t(),
          path: String.t(),
          expires: float,
          httpOnly: boolean,
          secure: boolean,
          sameSite: String.t()
        }

  @typedoc "Supported events"
  @type event ::
          :background_page
          | :close
          | :page
          | :request
          | :request_failed
          | :request_finished
          | :response
          | :service_worker

  @typedoc "An optional (maybe nil) function or option"
  @type function_or_options :: fun() | options() | nil

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

  @typedoc "A string URL"
  @type url :: String.t()

  # Callbacks
  # ---------------------------------------------------------------------------

  @impl ChannelOwner
  def init(%BrowserContext{} = owner, _initializer) do
    {:ok, %{owner | browser: owner.parent}}
  end

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

  @doc """
  Adds cookies into this `Playwright.BrowserContext`.

  All pages within this context will have these cookies installed. Cookies can
  be obtained via `Playwright.BrowserContext.cookies/1`.

  ## Example

      :ok = BrowserContext.add_cookies(context, [cookie_1, cookie_2])

  ## Cookie fields

  | key         | description |
  | ----------  | ----------- |
  | `:name`     | |
  | `:value`    | |
  | `:url`      | *(optional)* either url or domain / path are required |
  | `:domain`   | *(optional)* either url or domain / path are required |
  | `:path`     | *(optional)* either url or domain / path are required |
  | `:expires`  | *(optional)* Unix time in seconds. |
  | `:httpOnly` | *(optional)* |
  | `:secure`   | *(optional)* |
  | `:sameSite` | *(optional)* one of "Strict", "Lax", "None" |
  """
  @spec add_cookies(BrowserContext.t(), [cookie]) :: :ok
  def add_cookies(owner, cookies)

  def add_cookies(%BrowserContext{} = owner, cookies) do
    {:ok, _} = Channel.post(owner, :add_cookies, %{cookies: cookies})
    :ok
  end

  # ---

  # @spec add_init_script(BrowserContext.t(), binary(), options()) :: :ok
  # def add_init_script(owner, script, options \\ %{})

  # @spec background_pages(BrowserContext.t()) :: {:ok, [Playwright.Page.t()]}
  # def background_pages(owner)

  # @spec browser(BrowserContext.t()) :: {:ok, Playwright.Browser.t()}
  # def browser(owner)

  # @spec clear_cookies(BrowserContext.t()) :: :ok
  # def clear_cookies(owner)

  # @spec clear_permissions(BrowserContext.t()) :: :ok
  # def clear_permissions(owner)

  # ---

  @doc """
  Closes the `Playwright.BrowserContext`. All pages that belong to the
  `Playwright.BrowserContext` will be closed.

  > NOTE:
  > - The default browser context cannot be closed.
  """
  @spec close(BrowserContext.t()) :: :ok
  def close(%BrowserContext{} = owner) do
    {:ok, _} = Channel.post(owner, :close)
    :ok
  end

  def close({:ok, owner}) do
    close(owner)
  end

  @doc """
  Returns cookies for the `Playwright.BrowserContext`.

  If no URLs are specified, this method returns all cookies. If URLs are
  specified, only cookies that affect those URLs are returned.

  | param  | description |
  | ------ | ----------- |
  | `urls` | *(optional)* List of URLs |

  See `add_cookies/2` for cookie field details.
  """
  @spec cookies(BrowserContext.t(), url | [url]) :: {:ok, [cookie]}
  def cookies(owner, urls \\ [])

  def cookies(%BrowserContext{} = owner, urls) do
    Channel.post(owner, :cookies, %{urls: urls})
  end

  @doc """
  Waits for an event to fire (i.e., is blocking) and passes its value into the
  predicate function.

  Returns when the predicate returns a truthy value. Throws an error if the
  context closes before the event is fired. Returns a `Playwright.Runner.EventInfo`.

  ## Arguments

  - `event`: Event name; the same as those passed to `Playwright.BrowserContext.on/3`
  - `predicate`: Receives the `Playwright.Runner.EventInfo` and resolves to a
    "truthy" value when the waiting should resolve.
  - `options`:
    - `predicate`: ...
    - `timeout`: The maximum time to wait in milliseconds. Defaults to 30000
      (30 seconds). Pass 0 to disable timeout. The default value can be changed
      via `Playwright.BrowserContext.set_default_timeout/2`.

  ## Example

      {:ok, event_info} = BrowserContext.expect_event(owner, :page, fn ->
        BrowserContext.new_page(owner)
      end)

  > NOTE:
  > - The "throw an error if the context closes..." is not yet implemented.
  > - The handling of :predicate is not yet implemented.
  """
  @spec expect_event(BrowserContext.t() | Page.t(), atom() | binary(), fun(), function_or_options(), map()) ::
          {:ok, Playwright.Runner.EventInfo.t()}
  def expect_event(owner, event, trigger, predicate \\ nil, options \\ %{})

  def expect_event(%BrowserContext{} = owner, event, trigger, _predicate, _options)
      when is_function(trigger) do
    Channel.wait_for(owner, event, trigger)
  end

  def expect_event(%BrowserContext{} = owner, event, trigger, options, _)
      when is_map(options) do
    expect_event(owner, event, trigger, nil, options)
  end

  def expect_event(%Page{} = owner, event, trigger, predicate, options) do
    Page.context(owner) |> expect_event(event, trigger, predicate, options)
  end

  # def expect_event({:ok, owner}, event, trigger, predicate, options) do
  #   expect_event(owner, event, trigger, predicate, options)
  # end

  defdelegate wait_for_event(owner, event, trigger, predicate \\ nil, options \\ %{}),
    to: __MODULE__, as: :expect_event

  @doc """
  Executes `trigger` and waits for a new `Playwright.Page` to be created within
  the `Playwright.BrowserContext`.

  If `predicate` is provided, it passes the `Playwright.Page` value into the
  predicate function, wrapped in `Playwright.Runner.EventInfo`, and waits for
  `predicate/1` to return a "truthy" value. Throws an error if the context
  closes before new `Playwright.Page` is created.

  ## Arguments

  - `predicate`: Receives the `Playwright.Page` and resolves to truthy value
    when the waiting should resolve.
  - `options`:
    - `predicate`: ...
    - `timeout`: The maximum time to wait in milliseconds. Defaults to 30000
      (30 seconds). Pass 0 to disable timeout. The default value can be changed
      via `Playwright.BrowserContext.set_default_timeout/2`.

  > NOTE:
  > - The handling of `predicate` is not yet implemented.
  > - The handling of `timeout` is not yet implemented.
  """
  @spec expect_page(BrowserContext.t(), fun(), function_or_options(), map()) :: {:ok, Playwright.Runner.EventInfo.t()}
  def expect_page(owner, trigger, predicate \\ nil, options \\ %{})

  def expect_page(%BrowserContext{} = owner, trigger, predicate, options)
      when is_function(trigger) do
    expect_event(owner, :page, trigger, predicate, options)
  end

  def expect_page(%BrowserContext{} = owner, trigger, options, _)
      when is_map(options) do
    expect_page(owner, trigger, nil, options)
  end

  defdelegate wait_for_page(owner, trigger, predicate \\ nil, options \\ %{}),
    to: __MODULE__, as: :expect_page

  # ---

  # @spec expose_binding(BrowserContext.t(), String.t(), function(), options()) :: :ok
  # def expose_binding(owner, name, callback, options \\ %{})

  # @spec expose_function(BrowserContext.t(), String.t(), function()) :: :ok
  # def expose_function(owner, name, callback)

  # @spec grant_permissions(BrowserContext.t(), [String.t()], options()) :: :ok
  # def grant_permissions(owner, permission, options \\ %{})

  # @spec new_cdp_session(BrowserContext.t(), Page.t()) :: {:ok, Playwright.CDPSession.t()}
  # def new_cdp_session(owner, page)

  # ---

  @doc """
  Creates a new `Playwright.Page` in the context.

  If the context is already "owned" by a `Playwright.Page` (i.e., was created
  as a side effect of `Playwright.Browser.new_page/1`), will raise an error
  because there should be a 1-to-1 mapping in that case.
  """
  @spec new_page(BrowserContext.t()) :: {:ok, Page.t()}
  def new_page(%BrowserContext{} = owner) do
    case owner.owner_page do
      nil ->
        Channel.post(owner, :new_page)

      %Playwright.Page{} ->
        raise(RuntimeError, message: "Please use Playwright.Browser.new_context/1")
    end
  end

  def new_page({:ok, owner}) do
    new_page(owner)
  end

  # ---

  # @spec pages(BrowserContext.t()) :: {:ok, [Page.t()]}
  # def pages(owner)

  # ---

  @doc """
  Register a (non-blocking) callback/handler for various types of events.
  """
  @spec on(BrowserContext.t(), event(), function()) :: {:ok, BrowserContext.t()}
  def on(%BrowserContext{} = owner, event, callback) do
    Channel.bind(owner, event, callback)
  end

  # ---

  # @spec route(BrowserContext.t(), String.t(), function(), options()) :: :ok
  # def route(owner, url_pattern, handler, options \\ %{})

  # @spec service_workers(BrowserContext.t()) :: {:ok, [Playwright.Worker.t()]}
  # def service_workers(owner)

  # @spec set_default_navigation_timeout(BrowserContext.t(), number()) :: :ok
  # def set_default_navigation_timeout(owner, timeout)

  # @spec set_default_imeout(BrowserContext.t(), number()) :: :ok
  # def set_default_imeout(owner, timeout)

  # @spec set_extra_http_headers(BrowserContext.t(), headers()) :: :ok
  # def set_extra_http_headers(owner, headers)

  # @spec set_geolocation(BrowserContext.t(), geolocation()) :: :ok
  # def set_geolocation(owner, geolocation)

  # @spec set_http_credentials(BrowserContext.t(), http_credentials()) :: :ok
  # def set_http_credentials(owner, http_credentials)

  # @spec set_offline(BrowserContext.t(), boolean()) :: :ok
  # def set_offline(owner, offline)

  # @spec storage_state(BrowserContext.t(), String.t()) :: {:ok, storage_state()}
  # def storage_state(owner, path \\ nil)

  # @spec route(BrowserContext.t(), String.t(), options()) :: :ok
  # def route(owner, url_pattern, options \\ %{})

  # ---
end