diff --git a/lib/playwright.ex b/lib/playwright.ex index 6b780f4d..02088f80 100644 --- a/lib/playwright.ex +++ b/lib/playwright.ex @@ -8,8 +8,8 @@ defmodule Playwright do alias Playwright.API.{Browser, Page, Response} - {:ok, browser} = Playwright.launch(:chromium) - {:ok, page} = Browser.new_page(browser) + {:ok, session, browser} = Playwright.launch(:chromium) + {:ok, page} = Browser.new_page(browser) {:ok, response} = Page.goto(browser, "http://example.com") assert Response.ok(response) @@ -18,6 +18,9 @@ defmodule Playwright do """ use Playwright.SDK.ChannelOwner + alias Playwright + alias Playwright.APIRequest + alias Playwright.SDK.Channel alias Playwright.SDK.Config @property :chromium @@ -33,6 +36,9 @@ defmodule Playwright do # @typedoc "Options for `launch`." # @type launch_options :: Playwright.SDK.Config.launch_options() + # API + # --------------------------------------------------------------------------- + @doc """ Initiates an instance of `Playwright.Browser` use the WebSocket transport. @@ -55,7 +61,7 @@ defmodule Playwright do options = Map.merge(Config.connect_options(), options) {:ok, session} = new_session(Playwright.SDK.Transport.WebSocket, options) {:ok, browser} = new_browser(session, client, options) - {:ok, browser} + {:ok, session, browser} end @doc """ @@ -67,7 +73,7 @@ defmodule Playwright do ## Arguments - | key/name | typ | | description | + | key/name | type | | description | | ----------| ----- | ----------- | ----------- | | `client` | param | `client()` | The type of client (browser) to launch. | | `options` | param | `options()` | `Playwright.SDK.Config.launch_options()` | @@ -77,7 +83,11 @@ defmodule Playwright do options = Map.merge(Config.launch_options(), options) {:ok, session} = new_session(Playwright.SDK.Transport.Driver, options) {:ok, browser} = new_browser(session, client, options) - {:ok, browser} + {:ok, session, browser} + end + + def request(session) do + APIRequest.new(session) end # private @@ -85,16 +95,21 @@ defmodule Playwright do defp new_browser(session, client, options) when is_atom(client) and client in [:chromium, :firefox, :webkit] do - with play <- Playwright.SDK.Channel.find(session, {:guid, "Playwright"}), + with play <- Channel.find(session, {:guid, "Playwright"}), guid <- Map.get(play, client)[:guid] do - {:ok, Playwright.SDK.Channel.post(session, {:guid, guid}, :launch, options)} + playwright = %Playwright{ + guid: guid, + session: session + } + + {:ok, Channel.post({playwright, :launch}, options)} end end defp new_session(transport, args) do DynamicSupervisor.start_child( - Playwright.SDK.Channel.Session.Supervisor, - {Playwright.SDK.Channel.Session, {transport, args}} + Channel.Session.Supervisor, + {Channel.Session, {transport, args}} ) end end diff --git a/lib/playwright/sdk/channel/error.ex b/lib/playwright/api/error.ex similarity index 56% rename from lib/playwright/sdk/channel/error.ex rename to lib/playwright/api/error.ex index ef368818..908de13b 100644 --- a/lib/playwright/sdk/channel/error.ex +++ b/lib/playwright/api/error.ex @@ -1,29 +1,37 @@ -defmodule Playwright.SDK.Channel.Error do +defmodule Playwright.API.Error do @moduledoc false # `Error` represents an error message received from the Playwright server # that is in response to a `Message` previously sent. - alias Playwright.SDK.Channel @enforce_keys [:type, :message] - defstruct [:type, :message] + defstruct [:type, :message, :wrapped] @type t() :: %__MODULE__{ type: String.t(), - message: String.t() + message: String.t(), + wrapped: any() } + def new(%{error: %{name: name, message: message, wrapped: original} = _error}, _catalog) do + %__MODULE__{ + type: name, + message: String.split(message, "\n") |> List.first(), + wrapped: original + } + end + def new(%{error: %{name: name, message: message} = _error}, _catalog) do - %Channel.Error{ + %__MODULE__{ type: name, message: String.split(message, "\n") |> List.first() } end - # TODO: determine why we get here... - # DONE: see comment at error_handling.ex:9. def new(%{error: %{message: message} = _error}, _catalog) do - %Channel.Error{ - type: "NotImplementedError", + # dbg(error) + + %__MODULE__{ + type: "UnknownError", message: String.split(message, "\n") |> List.first() } end diff --git a/lib/playwright/api/errors/web_error.ex b/lib/playwright/api/errors/web_error.ex deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/playwright/api_request.ex b/lib/playwright/api_request.ex index f743231d..2dffe467 100644 --- a/lib/playwright/api_request.ex +++ b/lib/playwright/api_request.ex @@ -1,7 +1,296 @@ defmodule Playwright.APIRequest do - @moduledoc false - use Playwright.SDK.ChannelOwner + @moduledoc """ + `Playwright.APIRequest` exposes an API to be used for the web API testing. - # @spec new_context(t(), options()) :: APIRequestContext.t() - # def new_context(api_request, options \\ %{}) + The module is used for creating `Playwright.APIRequestContext` instances, + which in turn may be used for sending web requests. An instance of this + modeule may be obtained via `Playwright.request/1`. + + For more usage details, see `Playwright.APIRequestContext`. + """ + + use Playwright.SDK.Pipeline + alias Playwright.API.Error + alias Playwright.APIRequest + alias Playwright.APIRequestContext + alias Playwright.SDK.Channel + + # structs & types + # ---------------------------------------------------------------------------- + + @enforce_keys [:guid, :session] + defstruct [:guid, :session] + + @typedoc """ + `#{String.replace_prefix(inspect(__MODULE__), "Elixir.", "")}` + """ + @type t() :: %__MODULE__{ + guid: binary(), + session: pid() + } + + @typedoc "Options for calls to `new_context/1`" + @type options :: %{ + optional(:base_url) => String.t(), + optional(:client_certificates) => [client_certificate()], + optional(:extra_http_headers) => http_headers(), + optional(:http_credentials) => http_credentials(), + optional(:ignore_https_errors) => boolean(), + optional(:proxy) => proxy_settings(), + optional(:storage_state) => storage_state() | Path.t() | String.t(), + optional(:timeout) => float(), + optional(:user_agent) => String.t() + } + + @typedoc """ + A client TLS certificate to be used in requests. + """ + @type client_certificate :: %{ + required(:origin) => String.t(), + optional(:cert_path) => Path.t() | String.t(), + optional(:key_path) => Path.t() | String.t(), + optional(:pfx_path) => Path.t() | String.t(), + optional(:passphrase) => String.t() + } + + @typedoc "A `map` containing additional HTTP headers to be sent with every request." + @type http_headers :: %{required(String.t()) => String.t()} + + @typedoc "HTTP authetication credentials." + @type http_credentials :: %{ + required(:username) => String.t(), + required(:password) => String.t(), + optional(:origin) => String.t(), + optional(:send) => :always | :unauthorized + } + + @typedoc "Network proxy settings." + @type proxy_settings :: %{ + required(:server) => String.t(), + optional(:bypass) => String.t(), + optional(:username) => String.t(), + optional(:password) => String.t() + } + + @typedoc "Storage state settings." + @type storage_state :: %{ + required(:cookies) => [cookie()], + required(:origins) => [ + %{ + required(:origin) => String.t(), + required(:local_storage) => [local_storage()] + } + ] + } + + @typedoc "An HTTP cookie." + @type cookie :: %{ + optional(:name) => String.t(), + optional(:value) => String.t(), + required(:domain) => String.t(), + required(:path) => String.t(), + optional(:expires) => float(), + optional(:http_only) => boolean(), + optional(:secure) => boolean(), + # same_site: "Lax" | "None" | "Strict" + optional(:same_site) => String.t() + } + + @typedoc "Local storage settings." + @type local_storage :: %{ + required(:name) => String.t(), + required(:value) => String.t() + } + + # API + # ---------------------------------------------------------------------------- + + @doc """ + Returns a new `Playwright.APIRequest`. + + See also `Playwright.request/1` which is a more likely entry-point. + + ## Usage + + request = APIRequest.new(session) + + ## Arguments + + | name | description | + | --------- | -------------------------------------------- | + | `session` | The `pid` for the current Playwright session | + + ## Returns + + - `Playwright.APIRequest.t()` + """ + @spec new(pid()) :: t() + def new(session) do + %__MODULE__{ + guid: "Playwright", + session: session + } + end + + @doc """ + Creates a new instance of `Playwright.APIRequestContext`. + + ## Usage + + request = Playwright.request(session) + + APIRequest.new_context(request) + APIRequest.new_context(request, options) + + APIRequest.new_context!(request) + APIRequest.new_context!(request, options) + + ## Arguments + + | name | | description | + | --------- | ---------- | -------------------------- | + | `request` | | The "subject" `APIRequest` | + | `options` | (optional) | `APIRequest.options()` | + + ## Options + +
+ + ### Option: `:base_url` + + Functions such as `Playwright.APIRequestContext.get/3` take the base URL into + consideration by using the [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) + constructor for building the corresponding require URL. + + #### Examples + + - With `base_url: http://localhost:3000`, sending a request to `/bar.html` + results in `http://localhost:3000/bar.html`. + - With `base_url: http://localhost:3000/foo/`, sending a request to `/bar.html` + results in `http://localhost:3000/foo/bar.html`. + - With `base_url: http://localhost:3000/foo` (without the trailing slash), + navigating to `./bar.html` results in `http://localhost:3000/bar.html`. + +
+ + ### Option: `:client_certificates` + + A list of client certificates to be used. Each certificate instance must have + both `:cert_path` and `:key_path` or a single `:pfx_path` to load the client + certificate. Optionally, the `:passphrase` property should be provided if the + certficiate is encrypted. The `:origin` property should be provided with an + exact match to the request origin for which the certificate is valid. + + TLS client authentication allows the server to request a client certificate + and verify it. + + > #### NOTE {: .info} + > + > Using client certificates in combination with proxy servers is not supported. + + > #### NOTE {: .info} + > + > When using WebKit on macOS, accessing `localhost` will not pick up client + > certificates. As a work-around: replace `localhost` with `local.playwright`. + + #### Details + + | name | | description | + | ------------- | ---------- | --------------------------------- | + | `:origin` | | Exact origin that the certificate is valid for. Origin includes https protocol, a hostname and optionally a port. | + | `:cert_path` | (optional) | Path to the file with the certificate in PEM format. | + | `:key_path` | (optional) | Path to the file with the private key in PEM format. | + | `:pfx_path` | (optional) | Path to the PFX or PKCS12 encoded private key and certificate chain. | + | `:passphrase` | (optional) | Passphrase for the private key (PEM or PFX). | + +
+ + ### Option: `:extra_http_headers` + + A `map` containing additional HTTP headers to be sent with every request. + +
+ + ### Option: `:http_credentials` + + Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). + If no `:origin` is specified, the `:username` and `:password` are sent to any + servers upon unauthorized responses. + + #### Details + + | name | | description | + | ----------- | ---------- | ----------- | + | `:username` | | | + | `:password` | | | + | `:origin` | (optional) | Restrain sending http credentials on specific origin (`scheme://host:port`). | + | `:send` | (optional) | This option only applies to the requests sent from corresponding `APIRequestContext` and does not affect requests sent from the browser. `:always` - `Authorization` header with basic authentication credentials will be sent with the each API request. `:unauthorized`- the credentials are only sent when 401 (Unauthorized) response with `WWW-Authenticate` header is received. Defaults to `:unauthorized`. | + +
+ + ### Option: `:ignore_https_errors` + + Whether to ignore HTTPS errors when sending network requests. Defaults to + `false`. + +
+ + ### Option: `:proxy` + + Network proxy settings. + + | name | | description | + | ----------- | ---------- | ----------- | + | `:server` | | Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. | + | `:bypass` | (optional) | Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. | + | `:username` | (optional) | Optional username to use if HTTP proxy requires authentication. | + | `:password` | (optional) | Optional password to use if HTTP proxy requires authentication. | + +
+ + ### Option: `:storage_state` + + Populates context with given storage state. + + This option can be used to initialize context with logged-in information + obtained via, either, a path to the file with saved storage, or the value + returned by one of `BrowserContext.storage_state/2` or + `APIRequestContext.storage_state/2`. + + | name | | description | + | ----------- | ---------- | ----------- | + | `:cookies` | | `[APIRequest.cookie()]` | + | `:origins` | | `[APIRequest.origin()]` | + +
+ + ### Option: `:timeout` + + Maximum time in milliseconds to wait for the response. Defaults to `30_000` + (30 seconds). Pass `0` to disable the timeout. + +
+ + ### Option: `:user_agent` + + Specific user agent to use in this context. + + ## Returns + + - `Playwright.APIRequestContext.t()` + - `{:error, Playwright.API.Error.t()}` + """ + @pipe {:new_context, [:request]} + @pipe {:new_context, [:request, :options]} + @spec new_context(t(), options()) :: APIRequestContext.t() | {:error, Error.t()} + def new_context(request, options \\ %{}) + + def new_context(%APIRequest{} = request, %{storage_state: storage} = options) when is_binary(storage) do + storage = Jason.decode!(File.read!(storage)) + new_context(request, Map.merge(options, %{storage_state: storage})) + end + + def new_context(%APIRequest{} = request, options) do + Channel.post({request, :new_request}, options) + end end diff --git a/lib/playwright/api_request_context.ex b/lib/playwright/api_request_context.ex index 66692506..0205ade8 100644 --- a/lib/playwright/api_request_context.ex +++ b/lib/playwright/api_request_context.ex @@ -1,73 +1,538 @@ defmodule Playwright.APIRequestContext do @moduledoc """ - This API is used for the Web API testing. You can use it to trigger API endpoints, configure micro-services, - prepare environment or the server to your e2e test. + `Playwright.APIRequestContext` is useful for testing of web APIs. - Use this at caution as has not been tested. + The module may be used to trigger API endpoints, configure micro-services, + prepare environment or services in end-to-end (e2e) tests. + Each `Playwright.BrowserContext` (browser context) has an associated + `Playwright.APIRequestContext` (API context) instance that shares cookie + storage with the browser context and can be accessed via + `Playwright.BrowserContext.request/1` or `Playwright.Page.request/1`. + It is also possible to create a new `Playwright.APIRequestContext` instance + via `Playwright.APIRequest.newContext/2`. + + ## Cookie management + + An `Playwright.APIRequestContext` returned by `Playwright.BrowserContext.request/1` + or `Playwright.Page.request/1` shares cookie storage with the corresponding + `Playwright.BrowserContext`. Each API request will have a cookie HTTP header + populated with the values from the browser context. If the API response + contains a `Set-Cookie` header, it will automatically update `Playwright.BrowserContext` + cookies and requests made from the page will pick up the changes. This means + that if you authenticate using this API, your e2e test will be authenticated. + + If you want API requests to not interfere with the browser cookies, create a + new `Playwright.APIRequestContext` via `Playwright.APIRequest.new_context/1`. + Such API contexts will have isolated cookie storage. + + ## Shared options + + The following options are available for all forms of request: + + | name | | description | + | ---------------------- | ---------- | --------------------------------- | + | `:data` | (optional) | Sets post data of the request. If the `:data` parameter is a `serializable()`, it will be serialized as a JSON string and the `content-type` HTTP header will be set to `application/json`, if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. | + | `:fail_on_status_code` | (optional) | Whether to raise an error for response codes other than `2xx` and `3xx`. By default, a `Playwright.APIResponse` is returned for all status codes. | + | `:form` | (optional) | Provides content that will be serialized as an HTML form using `application/x-www-form-urlencoded` encoding and sent as the request body. If this parameter is specified, the `content-type` HTTP header will be set to `application/x-www-form-urlencoded` unless explicitly provided. | + | `:headers` | (optional) | Set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by it. | + | `:ignore_https_errors` | (optional) | Whether to ignore HTTPS errors when sending network requests. Defaults to `false`. | + | `:max_redirects` | (optional) | Maximum number of request redirects that will be followed automatically. An error will be thrown if the number is exceeded. Defaults to `20`. Pass `0` to not follow redirects. | + | `:max_retries` | (optional) | Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. | + | `:method` | (optional) | If set changes the fetch method (e.g. [`PUT`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) or [`POST`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST)). If not specified, [`GET`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) method is used. | + | `:multipart` | (optional) | Provides content that will be serialized as an HTML form using `multipart/form-data` encoding and sent as the request body. If this parameter is specified, the `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed either as a `Multipart.t()` or as file-like object containing file name, mime-type and content. | + | `:params` | (optional) | Query parameters to be sent with the URL. | + | `:timeout` | (optional) | Request timeout in milliseconds. Defaults to `30_000` (30 seconds). Pass `0` to disable timeout. | + + When constructing a `:multipart` parameter as a `form()`, the following fields + should be defined:application + + - `:name` - File name (`String.t()`) + - `:mime_type` - File type (`String.t()`) + - `:buffer` - File content (`binary()`) """ use Playwright.SDK.ChannelOwner alias Playwright.APIRequestContext + alias Playwright.APIResponse + alias Playwright.Request + alias Playwright.API.Error + alias Playwright.SDK.Channel + + # structs & types + # ---------------------------------------------------------------------------- + + @typedoc "Options for the various request types." + @type options :: %{ + # TODO: support the equivalent of TypeScript's `Buffer` + optional(:data) => serializable() | String.t(), + optional(:fail_on_status_code) => boolean(), + optional(:form) => form(), + optional(:headers) => http_headers(), + optional(:ignore_https_errors) => boolean(), + optional(:max_redirects) => number(), + optional(:max_retries) => number(), + optional(:method) => String.t(), + # TODO: support the equivalent of TypeScript's `ReadStream` + optional(:multipart) => Multipart.t() | form(), + optional(:params) => form() | String.t(), + optional(:timeout) => float() + } + + @typedoc "A data structure for form content." + @type form :: %{ + required(String.t()) => binary() | boolean() | float() | String.t() + } + + @typedoc "A `map` containing additional HTTP headers to be sent with every request." + @type http_headers :: %{required(String.t()) => String.t()} + + @typedoc "Data serializable as JSON." + @type serializable :: list() | map() + + @typedoc "Storage state settings." + @type storage_state :: %{ + required(:cookies) => [cookie()], + required(:origins) => [ + %{ + required(:origin) => String.t(), + required(:local_storage) => [local_storage()] + } + ] + } + + @typedoc "An HTTP cookie." + @type cookie :: %{ + optional(:name) => String.t(), + optional(:value) => String.t(), + required(:domain) => String.t(), + required(:path) => String.t(), + optional(:expires) => float(), + optional(:http_only) => boolean(), + optional(:secure) => boolean(), + # same_site: "Lax" | "None" | "Strict" + optional(:same_site) => String.t() + } + + @typedoc "Local storage settings." + @type local_storage :: %{ + required(:name) => String.t(), + required(:value) => String.t() + } + + @typedoc "Options for `dispose/2`." + @type opts_dispose :: %{ + optional(:reason) => String.t() + } + + @typedoc "Options for `storage_state/2`." + @type opts_storage :: %{ + optional(:path) => String.t() + } + + # API + # ---------------------------------------------------------------------------- + + @doc """ + Sends an HTTP(S) [`DELETE`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE) + request and returns its response. + + Function invocation will populate request cookies from the context, and update + context cookies from the response. Calls automatically follow redirects. + + ## Usage + + request = Playwright.request(session) + context = APIRequest.new_context(request) + + APIRequest.delete(context, "https://example.com/api/books") + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `url` | | Target URL | + | `options` | (optional) | `APIRequestContext.options()` | + + ## Options + + See "Shared options" above. + + ## Returns + + - `Playwright.APIResponse.t()` + - `{:error, Error.t()}` + """ + @pipe {:delete, [:context, :url]} + @pipe {:delete, [:context, :url, :options]} + @spec delete(t(), binary(), options()) :: t() | {:error, Error.t()} + def delete(context, url, options \\ %{}) + + def delete(%APIRequestContext{} = context, url, options) do + fetch(context, url, Map.merge(options, %{method: "DELETE"})) + end + + @doc """ + Disposes of resources related to this `Playwright.APIRequestContext`. + + All responses returned by `Playwright.APIRequestContext.fetch/3` and similar + are stored in memory in order to support later, cached calls to + `Playwright.APIResponse.body/1`, etc. `dispose/1` discards all associated + resources. Subsequent calls to any function on disposed `APIRequestContext` + will result in errors. + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `options` | (optional) | Options (see below) | + + ## Options + + | name | | description | + | -------- | ---------- | --------------------------------- | + | `reason` | (optional) | The reason to be reported to any operations interrupted by the context disposal. | + + ## Returns + + - `:ok` + - `{:error, %Error{}}` + """ + @pipe {:dispose, [:context]} + @pipe {:dispose, [:context, :options]} + @spec dispose(t(), opts_dispose()) :: :ok | {:error, Error.t()} + def dispose(context, options \\ %{}) + + def dispose(%APIRequestContext{} = context, options) do + case Channel.post({context, "dispose"}, options, %{refresh: false}) do + {:error, %Playwright.API.Error{} = error} -> + {:error, error} + + _ -> + :ok + end + end + + # --- + + @doc """ + Sends an HTTP(S) request and returns the response (`Playwright.APIResponse`). + + Function invocation will populate request cookies from the context, and update + context cookies from the response. + + ## Usage - @type fetch_options() :: %{ - optional(:params) => any(), - optional(:method) => binary(), - optional(:headers) => any(), - optional(:postData) => any(), - optional(:jsonData) => any(), - optional(:formData) => any(), - optional(:multipartData) => any(), - optional(:timeout) => non_neg_integer(), - optional(:failOnStatusCode) => boolean(), - optional(:ignoreHTTPSErrors) => boolean() + JSON objects may be passed directly to the request: + + request = Playwright.request(session) + context = APIRequest.new_context(request) + + APIRequest.fetch(context, "https://example.com/api/books", %{ + method: "POST", + data: %{ + author: "Jane Doe", + title: "Book Title" } + }) + + A common way to send file(s) in the body of a request is to upload them as + form fields with `multipart/form-data` encoding. + Use [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) to + construct the request body and pass that to the request via the `multipart` + parameter: + + data = Multipart.new() + |> Multipart.add_field("author", "Jane Doe") + |> Multipart.add_field("title", "Book Title") + |> Multipart.add_file("path/to/manuscript.md", name: "manuscript.md") + + APIRequest.fetch(context, "https://example.com/api/books", %{ + method: "POST", + multipart: data + }) + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `url` | | Target URL | + | `options` | (optional) | `APIRequestContext.options()` | + + ## Options + + See "Shared options" above. - # @spec delete(t(), binary(), options()) :: APIResponse.t() - # def delete(context, url, options \\ %{}) - - # @spec dispose(t()) :: :ok - # def dispose(api_request_context) - - # @spec fetch(t(), binary() | Request.t(), options()) :: APIResponse.t() - # def fetch(context, url_or_request, options \\ %{}) - - # @spec get(t(), binary(), options()) :: APIResponse.t() - # def get(context, url, options \\ %{}) - - # @spec head(t(), binary(), options()) :: APIResponse.t() - # def head(context, url, options \\ %{}) - - # @spec patch(t(), binary(), options()) :: APIResponse.t() - # def patch(context, url, options \\ %{}) - - @spec post(t(), binary(), fetch_options()) :: Playwright.APIResponse.t() - def post(%APIRequestContext{session: session} = context, url, options \\ %{}) do - Channel.post( - session, - {:guid, context.guid}, - :fetch, - Map.merge( - %{ - url: url, - method: "POST" - }, - options - ) - ) + ## Returns + + - `Playwright.APIResponse.t()` + - `{:error, Error.t()}` + """ + @pipe {:fetch, [:context, :url_or_request]} + @pipe {:fetch, [:context, :url_or_request, :options]} + @spec fetch(t(), binary() | Request.t(), options()) :: APIResponse.t() | {:error, Error.t()} + def fetch(context, url_or_request, options \\ %{}) + + def fetch(%APIRequestContext{} = context, url, options) when is_binary(url) do + case Channel.post({context, :fetch}, %{url: url, method: "GET"}, options) do + {:error, _} = error -> + error + + response -> + APIResponse.new(Map.merge(response, %{context: context})) + end end - # @spec put(t(), binary(), options()) :: APIResponse.t() - # def put(context, url, options \\ %{}) + @doc """ + Sends an HTTP(S) [`GET`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) + request and returns its response. + + Function invocation will populate request cookies from the context, and update + context cookies from the response. Calls automatically follow redirects. + + ## Usage + + request = Playwright.request(session) + context = APIRequest.new_context(request) + + APIRequest.get(context, "https://example.com/api/books", %{ + params: %{isbn: "1234", page: "23"} + }) + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `url` | | Target URL | + | `options` | (optional) | `APIRequestContext.options()` | + + ## Options + + See "Shared options" above. + + ## Returns + + - `Playwright.APIResponse.t()` + - `{:error, Error.t()}` + """ + @pipe {:get, [:context, :url]} + @pipe {:get, [:context, :url, :options]} + @spec get(t(), binary(), options()) :: t() | {:error, Error.t()} + def get(context, url, options \\ %{}) + + def get(%APIRequestContext{} = context, url, options) do + fetch(context, url, Map.merge(options, %{method: "GET"})) + end + + @doc """ + Sends an HTTP(S) [`HEAD`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD) + request and returns its response. + + Function invocation will populate request cookies from the context, and update + context cookies from the response. Calls automatically follow redirects. + + ## Usage + + request = Playwright.request(session) + context = APIRequest.new_context(request) + + APIRequest.head(context, "https://example.com/api/books") + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `url` | | Target URL | + | `options` | (optional) | `APIRequestContext.options()` | + + ## Options + + See "Shared options" above. + + ## Returns + + - `Playwright.APIResponse.t()` + - `{:error, Error.t()}` + """ + @pipe {:head, [:context, :url]} + @pipe {:head, [:context, :url, :options]} + @spec head(t(), binary(), options()) :: t() | {:error, Error.t()} + def head(context, url, options \\ %{}) + + def head(%APIRequestContext{} = context, url, options) do + fetch(context, url, Map.merge(options, %{method: "HEAD"})) + end + + @doc """ + Sends an HTTP(S) [`PATCH`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) + request and returns its response. + + Function invocation will populate request cookies from the context, and update + context cookies from the response. Calls automatically follow redirects. + + ## Usage + + request = Playwright.request(session) + context = APIRequest.new_context(request) + + APIRequest.patch(context, "https://example.com/api/books", %{ + data: %{title: "Updated"}, + params: %{isbn: "1234"} + }) + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `url` | | Target URL | + | `options` | (optional) | `APIRequestContext.options()` | + + ## Options + + See "Shared options" above. + + ## Returns + + - `Playwright.APIResponse.t()` + - `{:error, Error.t()}` + """ + @pipe {:patch, [:context, :url]} + @pipe {:patch, [:context, :url, :options]} + @spec patch(t(), binary(), options()) :: t() | {:error, Error.t()} + def patch(context, url, options \\ %{}) + + def patch(%APIRequestContext{} = context, url, options) do + fetch(context, url, Map.merge(options, %{method: "PATCH"})) + end + + @doc """ + Sends an HTTP(S) [`POST`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) + request and returns its response. + + Function invocation will populate request cookies from the context, and update + context cookies from the response. Calls automatically follow redirects. + + ## Usage + + request = Playwright.request(session) + context = APIRequest.new_context(request) + + APIRequest.post(context, "https://example.com/api/books", %{ + data: %{title: "Updated"} + }) + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `url` | | Target URL | + | `options` | (optional) | `APIRequestContext.options()` | + + ## Options + + See "Shared options" above. + + ## Returns + + - `Playwright.APIResponse.t()` + - `{:error, Error.t()}` + """ + @pipe {:post, [:context, :url]} + @pipe {:post, [:context, :url, :options]} + @spec post(t(), binary(), options()) :: t() | {:error, Error.t()} + def post(context, url, options \\ %{}) + + def post(%APIRequestContext{} = context, url, options) do + fetch(context, url, Map.merge(options, %{method: "POST"})) + end + + @doc """ + Sends an HTTP(S) [`PUT`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) + request and returns its response. + + Function invocation will populate request cookies from the context, and update + context cookies from the response. Calls automatically follow redirects. + + ## Usage + + request = Playwright.request(session) + context = APIRequest.new_context(request) + + APIRequest.put(context, "https://example.com/api/books", %{ + data: %{title: "Updated"} + }) + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `url` | | Target URL | + | `options` | (optional) | `APIRequestContext.options()` | + + ## Options + + See "Shared options" above. + + ## Returns + + - `Playwright.APIResponse.t()` + - `{:error, Error.t()}` + """ + @pipe {:put, [:context, :url]} + @pipe {:put, [:context, :url, :options]} + @spec put(t(), binary(), options()) :: t() | {:error, Error.t()} + def put(context, url, options \\ %{}) + + def put(%APIRequestContext{} = context, url, options) do + fetch(context, url, Map.merge(options, %{method: "PUT"})) + end + + @doc """ + Returns storage state for this request context. + + The storage state contains current cookies and a local storage snapshot if it + was passed to the initializer. + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `options` | (optional) | Options (see below) | + + ## Options + + | name | | description | + | -------- | ---------- | --------------------------------- | + | `path` | (optional) | The file path to save the storage state. If path is a relative path, then it is resolved relative to current working directory. If no path is provided, storage state is still returned, but won't be saved to the disk. | + + ## Returns + + - `storage_state()` + - `{:error, Error.t()}` + """ + @pipe {:storage_state, [:context]} + @pipe {:storage_state, [:context, :options]} + @spec storage_state(t(), opts_storage()) :: storage_state() | {:error, Error.t()} + def storage_state(context, options \\ %{}) do + {path, options} = Map.pop(options, :path) - # @spec storage_state(t(), options()) :: StorageState.t() - # def storage_state(context, options \\ %{}) + case Channel.post({context, :storage_state}, options) do + {:error, _} = error -> + error - # TODO: move to `APIResponse.body`, probably. - @spec body(t(), Playwright.APIResponse.t()) :: any() - def body(%APIRequestContext{session: session} = context, response) do - Channel.post(session, {:guid, context.guid}, :fetch_response_body, %{ - fetchUid: response.fetchUid - }) + result -> + result = Map.new(result) + path && File.write!(path, Jason.encode!(result)) + result + end end end diff --git a/lib/playwright/api_response.ex b/lib/playwright/api_response.ex index aa8518e1..a1ef62fd 100644 --- a/lib/playwright/api_response.ex +++ b/lib/playwright/api_response.ex @@ -1,46 +1,233 @@ defmodule Playwright.APIResponse do - @moduledoc false - use Playwright.SDK.ChannelOwner + @moduledoc """ + `Playwright.APIResponse` represents responses returned by + `Playwrigh.APIRequestContext.fetch/3` and similar. + + ## Usage + + {:ok, session, _} = Playwright.launch() + request = Playwright.request(session) + context = APIRequest.new_context(request) + + response = APIRequest.get(context, "https://example.com") + json = APIResponse.json!(response) + """ + + use Playwright.SDK.Pipeline + alias Playwright.APIRequestContext alias Playwright.APIResponse + alias Playwright.API.Error + alias Playwright.SDK.Channel + + # structs & types + # ---------------------------------------------------------------------------- + + defstruct [:context, :fetchUid, :headers, :status, :statusText, :url] + + @typedoc """ + `#{String.replace_prefix(inspect(__MODULE__), "Elixir.", "")}` + """ + @type t() :: %__MODULE__{ + context: APIRequestContext, + fetchUid: String.t(), + headers: list(%{name: String.t(), value: String.t()}), + status: integer(), + statusText: String.t(), + url: String.t() + } + + @typedoc "Data serializable as JSON." + @type serializable() :: list() | map() + + # API + # ---------------------------------------------------------------------------- + + @doc """ + Returns a `Playwright.APIResponse` hydrated from the provided `properties`. + + ## Returns + + - `Playwright.APIResponse` + """ + @spec new(map()) :: t() + def new(properties) do + struct(__MODULE__, properties) + end + + @doc """ + Returns a buffer with the response body. + + ## Usage + + request = Playwright.request(session) |> APIRequest.new_context() + response = APIRequestContext.fetch("https://example.com") + APIResponse.body!(response) |> IO.puts() + + ## Returns + + - `binary()` + - `{:error, %Error{type: "ResponseError"}}` + """ + @pipe {:body, [:response]} + @spec body(t()) :: binary() | {:error, Error.t()} + def body(%APIResponse{} = response) do + case Channel.post({response.context, :fetch_response_body}, %{fetch_uid: response.fetchUid}) do + {:error, %Error{}} = error -> + error + + nil -> + {:error, Error.new(%{error: %{name: "ResponseError", message: "Response has been disposed"}}, nil)} + + result -> + Base.decode64!(result) + end + end + + @doc """ + Disposes the body of the response. If not called, the body will stay in memory + until the context closes. + + ## Returns + + - `:ok` + - `{:error, %Error{}}` + """ + @pipe {:dispose, [:response]} + @spec dispose(t()) :: :ok | {:error, Error.t()} + def dispose(%APIResponse{} = response) do + case Channel.post({response.context, "disposeAPIResponse"}, %{fetch_uid: response.fetchUid}) do + {:error, %Playwright.API.Error{} = error} -> + {:error, error} + + _ -> + :ok + end + end - @property :fetchUid - @property :headers - @property :status - @property :status_text - @property :url + @doc """ + Returns the value of a header. - # @spec body(t()) :: binary() # or, equivalent of `Buffer` - # def body(response) - # @spec dispose(t()) :: :ok - # def dispose(response) + ## Usage - # @spec headers(t()) :: map() - # def headers(response) + request = Playwright.request(session) |> APIRequest.new_context() + response = APIRequestContext.fetch("https://example.com") + APIResponse.header(response, "content-type") |> IO.puts() - # @spec headers(t()) :: map() - # def headers(response) + ## Arguments - # @spec headers_list(APIResponse.t()) :: [map()] - # def headers_list(response) + | name | | description | + | ---------- | ---------- | ----------------------------- | + | `response` | | The "subject" `APIResponse` | + | `name` | | The name of the HTTP header | - # @spec json(t()) :: binary() # "serializable"; so, maybe map()? - # def json(response) + ## Returns + - `binary()` + - `nil` + """ + @spec header(t(), atom() | String.t()) :: binary() | nil + def header(response, name) + + def header(%APIResponse{} = response, name) when is_atom(name) do + header(response, Atom.to_string(name)) + end + + def header(%APIResponse{} = response, name) when is_binary(name) do + case Enum.find(response.headers, fn header -> header.name == name end) do + nil -> + nil + + %{value: value} -> + value + end + end + + @doc """ + Returns a `map(name => value)` with all the response HTTP headers associated + with this response. + + ## Usage + + request = Playwright.request(session) |> APIRequest.new_context() + response = APIRequestContext.fetch("https://example.com") + APIResponse.headers(response) |> IO.inspect() + + ## Returns + + - `%{String.t() => String.t()}` + """ + @spec headers(t()) :: %{String.t() => String.t()} + def headers(%APIResponse{} = response) do + # Map.new([{1, 2}, {3, 4}]) + Enum.reduce(response.headers, %{}, fn %{name: name, value: value}, headers -> + Map.put(headers, name, value) + end) + end + + @doc """ + Returns a deserialized version of the JSON representation of response body. + + ## Usage + + request = Playwright.request(session) |> APIRequest.new_context() + response = APIRequestContext.fetch("https://example.com") + APIResponse.json!(response) |> IO.inspect() + + ## Returns + + - `serializable()` + - `{:error, %Error{name: "ResponseError"}}` + """ + @pipe {:json, [:response]} + @spec json(t()) :: serializable() | {:error, Error.t()} + def json(%APIResponse{} = response) do + case body(response) do + {:error, %Error{}} = error -> + error + + result -> + case Jason.decode(result) do + {:ok, decoded} -> + decoded + + {:error, original} -> + Error.new(%{error: %{name: "ResponseError", message: "Failed to decode response into JSON", original: original}}, nil) + end + end + end + + @doc """ + Returns a boolean indicating whether the response was successful. + + Success means the response status code is within the range of `200-299`. + + ## Returns + + - `boolean()` + """ @spec ok(t()) :: boolean() def ok(%APIResponse{} = response) do response.status === 0 || (response.status >= 200 && response.status <= 299) end - # @spec status(t()) :: number() - # def status(response) + @doc """ + Returns a text representation of response body. + + ## Usage - # @spec status_text(t()) :: binary() - # def status_text(response) + request = Playwright.request(session) |> APIRequest.new_context() + response = APIRequestContext.fetch("https://example.com") + APIResponse.text!(response) |> IO.puts() - # @spec text(t()) :: binary() - # def text(response) + ## Returns - # @spec url(t()) :: binary() - # def url(response) + - `binary()` + - `{:error, %Error{name: "ResponseError"}}` + """ + @pipe {:text, [:response]} + @spec text(t()) :: binary() | {:error, Error.t()} + def text(%APIResponse{} = response) do + body(response) + end end diff --git a/lib/playwright/artifact.ex b/lib/playwright/artifact.ex new file mode 100644 index 00000000..486f3da5 --- /dev/null +++ b/lib/playwright/artifact.ex @@ -0,0 +1,4 @@ +defmodule Playwright.Artifact do + @moduledoc false + use Playwright.SDK.ChannelOwner +end diff --git a/lib/playwright/binding_call.ex b/lib/playwright/binding_call.ex index bfd86dfc..44271826 100644 --- a/lib/playwright/binding_call.ex +++ b/lib/playwright/binding_call.ex @@ -2,7 +2,8 @@ defmodule Playwright.BindingCall do @moduledoc false use Playwright.SDK.ChannelOwner alias Playwright.BindingCall - alias Playwright.SDK.{Channel, Helpers} + alias Playwright.SDK.Channel + alias Playwright.SDK.Helpers.Serialization @property :args @property :frame @@ -19,8 +20,9 @@ defmodule Playwright.BindingCall do page: "TBD" } - result = func.(source, Helpers.Serialization.deserialize(binding_call.args)) - Channel.post(session, {:guid, binding_call.guid}, :resolve, %{result: Helpers.Serialization.serialize(result)}) + Channel.post({binding_call, :resolve}, %{ + result: Serialization.serialize(func.(source, Serialization.deserialize(binding_call.args))) + }) end) end end diff --git a/lib/playwright/browser.ex b/lib/playwright/browser.ex index e741ba7a..3333526e 100644 --- a/lib/playwright/browser.ex +++ b/lib/playwright/browser.ex @@ -8,8 +8,7 @@ defmodule Playwright.Browser do An example of using a `Playwright.Browser` to create a `Playwright.Page`: alias Playwright.{Browser, Page} - - {:ok, browser} = Playwright.launch(:chromium) + {:ok, session, browser} = Playwright.launch(:chromium) page = Browser.new_page(browser) Page.goto(page, "https://example.com") @@ -19,10 +18,277 @@ defmodule Playwright.Browser do - `:name` - `:version` + + ## Shared options + + The follow options are applicable to both: + + `Playwright.Browser.new_context/2` + `Playwright.Browser.new_page/2` + + | name | description | + | ---------------------- | --------------------------------- | + | `:accept_downloads` | Whether to automatically download all the attachments. Defaults to `true` where all the downloads are accepted. | + | `:base_url` | See details below. | + | `:bypass_csp` | Toggles bypassing page's Content-Security-Policy. Defaults to `false`. | + | `:client_certificates` | See details below. | + | `:color_scheme` | Emulates `"prefers-colors-scheme"` media feature, supported values are `"light"`, `"dark"`, `"no-preference"`. See `Playwright.Page.emulate_media/2` for more details. Passing `null` resets emulation to system defaults. Defaults to `"light"`. | + | `:device_scale_factor` | Specifies a device scale factor (can be thought of as `dpr`). Defaults to `1`. Learn more about [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). | + | `:extra_http_headers` | A `map` containing additional HTTP headers to be sent with every request. | + | `:force_colors` | Emulates `"forced-colors"` media feature, supported values are `"active"`, `"none"`. See `Playwright.Page.emulate_media/2` for more details. Passing `null` resets emulation to system defaults. Defaults to `"none"`. | + | `:geolocation` | See details below. | + | `:has_touch` | Specifies whether the viewport supports touch events. Defaults to `false`. Learn more about [mobile emulation](https://playwright.dev/docs/emulation#devices). | + | `:http_credentials` | See details below. | + | `:ignore_https_errors` | Whether to ignore HTTPS errors when sending network requests. Defaults to `false`. | + | `:locale` | Specifies the user locale. For example, `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, `Accept-Language` request header value as well as number and date formatting rules. Defaults to the system default locale. Learn more about emulation in the [emulation guide](https://playwright.dev/docs/emulation#locale--timezone). | + | `:logger` | Logger sink for Playwright logging. | + | `:offline` | Whether to emulate network being offline. Defaults to `false`. Learn more about [network emulation](https://playwright.dev/docs/emulation#offline). | + | `:permissions` | A list of permissions to grant to all pages in this context. See `Playwright.BrowserContext.grant_permissions/3` for more details. Defaults to `none`. | + | `:proxy` | See details below. | + | `:record_har` | See details below. | + | `:record_video` | See details below. | + | `:reduced_motion` | Emulates `"prefers-reduced-motion"` media feature, supported values are `"reduce"`, `"no-preference"`. See `Playwright.Page.emulate_media/2` for more details. Passing `null` resets emulation to system defaults. Defaults to `"no-preference"`. | + | `:screen` | See details below. | + | `:service_workers` | See details below. | + | `:strict_selectors` | If set to `true`, enables strict selectors mode for this context. In the strict selectors mode all operations on selectors that imply single target DOM element will throw when more than one element matches the selector. This option does not affect any `Playwright.Locator` APIs (Locators are always strict). Defaults to `false`. See `Playwright.Locator` to learn more about the strict mode. | + | `:timezone_id` | Changes the timezone of the context. See [ICU's metaZones.txt](ICU's metaZones.txt) for a list of supported timezone IDs. Defaults to the system timezone. | + | `:user_agent` | Specific user agent to use in this context. | + | `:video_size` | See details below. | + | `:videos_path` | See details below. | + | `:viewport` | See details below. | + +
+ + ### Option: `:base_url` + + When using `Page.goto/3`, `Page.route/4`, `Page.wait_for_url/3`, + `Page.wait_for_request/3`, or `Page.wait_for_response/3`, the base URL is + taken into consideration using the [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) + constructor for building the corresponding URL. Unset by default. + + #### Examples + + - With `base_url: http://localhost:3000`, sending a request to `/bar.html` + results in `http://localhost:3000/bar.html`. + - With `base_url: http://localhost:3000/foo/`, sending a request to `/bar.html` + results in `http://localhost:3000/foo/bar.html`. + - With `base_url: http://localhost:3000/foo` (without the trailing slash), + navigating to `./bar.html` results in `http://localhost:3000/bar.html`. + +
+ + ### Option: `:client_certificates` + + A list of client certificates to be used. Each certificate instance must have + both `:cert_path` and `:key_path` or a single `:pfx_path` to load the client + certificate. Optionally, the `:passphrase` property should be provided if the + certficiate is encrypted. The `:origin` property should be provided with an + exact match to the request origin for which the certificate is valid. + + TLS client authentication allows the server to request a client certificate + and verify it. + + > #### NOTE {: .info} + > + > Using client certificates in combination with proxy servers is not supported. + + > #### NOTE {: .info} + > + > When using WebKit on macOS, accessing `localhost` will not pick up client + > certificates. As a work-around: replace `localhost` with `local.playwright`. + + #### Details + + | name | | description | + | ------------- | ---------- | --------------------------------- | + | `:origin` | | Exact origin that the certificate is valid for. Origin includes https protocol, a hostname and optionally a port. | + | `:cert_path` | (optional) | Path to the file with the certificate in PEM format. | + | `:key_path` | (optional) | Path to the file with the private key in PEM format. | + | `:pfx_path` | (optional) | Path to the PFX or PKCS12 encoded private key and certificate chain. | + | `:passphrase` | (optional) | Passphrase for the private key (PEM or PFX). | + + ### Option: `:geolocation` + + Browser geolocation settings. + + #### Details + + | name | | description | + | ------------- | ---------- | --------------------------------- | + | `:latitude` | | Latitude between `-90` and `90`. | + | `:longitude` | | Longitude between `-180` and `180`. | + | `:accuracy` | (optional) | Non-negative accuracy value. Defaults to `0`. | + +
+ + ### Option: `:http_credentials` + + Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). + + This option only applies to the requests sent from corresponding a + `Playwright.APIRequestContext` and does not affect requests sent from the + `Browser`. + + #### Details + + | name | | description | + | ----------- | ---------- | ----------- | + | `:username` | | | + | `:password` | | | + | `:origin` | (optional) | Restrain sending http credentials on specific origin (`scheme://host:port`). | + | `:send` | (optional) | This option only applies to the requests sent from corresponding `APIRequestContext` and does not affect requests sent from the browser. `:always` - `Authorization` header with basic authentication credentials will be sent with the each API request. `:unauthorized`- the credentials are only sent when 401 (Unauthorized) response with `WWW-Authenticate` header is received. Defaults to `:unauthorized`. | + +
+ + ### Option: `:proxy` + + Network proxy settings. + + #### Details + + | name | | description | + | ----------- | ---------- | ----------- | + | `:server` | | Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. | + | `:bypass` | (optional) | Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. | + | `:username` | (optional) | Optional username to use if HTTP proxy requires authentication. | + | `:password` | (optional) | Optional password to use if HTTP proxy requires authentication. | + +
+ + ### Option: `:record_har` + + Enables HAR recording for all pages into `:record_har.path` file. If not + specified, the HAR is not recorded. Be sure to await + `Playwright.BrowserContext.close/1` for the HAR to be saved. + + #### Details + + | name | | description | + | --------------- | ---------- | ----------- | + | `:omit_content` | (optional) | Optional setting to control whether to omit request content from the HAR. Defaults to `false`. **Deprecated**; use content policy instead. | + | `:content` | (optional) | Optional setting to control resource content management. If `omit` is specified, content is not persisted. If `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output files and to `embed` for all other file extensions. | + | `:path` | | Path on the filesystem to write the HAR file. If the file name ends with `.zip`, `content: 'attach'` is used by default. | + | `:mode` | (optional) | When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. | + | `:url_filter` | (optional) | A glob or regex pattern to filter requests that are stored in the HAR. When a `:base_url` via the context options was provided and the passed URL is a path, it gets merged via the `new URL()` constructor. Defaults to `none`. | + +
+ + ### Option: `:record_video` + + Enables video recording for all pages into `:record_video.dir` directory. If + not specified videos are not recorded. Be sure to await + `Playwright.BrowserContext.close/1` for videos to be saved. + + #### Details + + | name | | description | + | --------------- | ---------- | ----------- | + | `:dir` | | Path to the directory for saving videos. | + | `:size` | (optional) | Video frame with and height: `%{width: number(), height: number()}`. | + | `:path` | | Path on the filesystem to write the HAR file. If the file name ends with `.zip`, `content: 'attach'` is used by default. | + | `:mode` | (optional) | When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. | + | `:url_filter` | (optional) | Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport` scaled down to fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of each page will be scaled down if necessary to fit the specified size. | + + - `:size`: + - `:width` - `number()`: Video frame width. + - `:height` - `number()`: Video frame height. + +
+ + ### Option: `:screen` + + Emulates consistent window screen size available inside web page via + `window.screen`. Is only used when the `viewport` is set. + + #### Details + + | name | | description | + | --------------- | ---------- | ----------- | + | `:width` | | Page width in pixels. | + | `:height` | | Page height in pixels. | + +
+ + ### Option: `:service_workers` + + Whether to allow sites to register Service workers. Defaults to `"allow"`. + + #### Details + + - `"allow"`: [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) can be registered. + - `"block"`: Playwright will block all registration of Service Workers. + +
+ + ### Option: `:storage_state` + + Populates context with given storage state. + + This option can be used to initialize context with logged-in information + obtained via, either, a path to the file with saved storage, or the value + returned by `BrowserContext.storage_state/2`. + + Learn more about [storage state and auth](https://playwright.dev/docs/auth). + + | name | | description | + | ----------- | ---------- | ----------- | + | `:cookies` | | `[Browser.cookie()]` | + | `:origins` | | `[Browser.origin()]` | + +
+ + ### Option: `:video_size` + + > #### DEPRECATED {: .warning} + > + > Use `:record_video` instead. + + #### Details + + | name | | description | + | --------- | ---------- | ----------- | + | `:width` | | Video frame width. | + | `:height` | | Video frame height. | + +
+ + ### Option: `:videos_path` + + > #### DEPRECATED {: .warning} + > + > Use `:record_video` instead. + +
+ + ### Option: `:viewport` + + Emulates consistent viewport for each page. Defaults to an 1280x720 viewport. + Use `null` to disable the consistent viewport emulation. Learn more about + [viewport emulation](https://playwright.dev/docs/emulation#viewport). + + > #### INFO {: .info} + > + > The `null` value opts out from the default presets, makes viewport depend on + > the host window size defined by the operating system. It makes the execution + > of the tests non-deterministic. + + #### Details + + | name | | description | + | --------- | ---------- | ----------- | + | `:width` | | Page width in pixels. | + | `:height` | | Page height in pixels. | """ use Playwright.SDK.ChannelOwner - alias Playwright.{Browser, BrowserContext, Page} - alias Playwright.SDK.{Channel, ChannelOwner, Extra} + alias ExUnit.DocTest.Error + alias Playwright.Browser + alias Playwright.BrowserContext + alias Playwright.BrowserType + alias Playwright.CDPSession + alias Playwright.Page + alias Playwright.SDK.Channel + alias Playwright.SDK.ChannelOwner + alias Playwright.SDK.Extra @property :name @property(:version, %{doc: "Returns the browser version"}) @@ -30,8 +296,198 @@ defmodule Playwright.Browser do @typedoc "Supported events" @type event :: :disconnected - @typedoc "A map/struct providing call options" - @type options :: map() + @typedoc "Options for `close/2`." + @type opts_close :: %{ + optional(:reason) => String.t() + } + + @typedoc "Options for `new_context/2` and `new_page/2`." + @type opts_new :: %{ + optional(:accept_downloads) => boolean(), + optional(:base_url) => String.t(), + optional(:bypass_csp) => boolean(), + optional(:client_certificates) => [client_certificate()], + optional(:color_scheme) => color_scheme(), + optional(:device_scale_factor) => number(), + optional(:extra_http_headers) => http_headers(), + optional(:forced_colors) => forced_colors(), + optional(:geolocation) => geolocation(), + optional(:has_touch) => boolean(), + optional(:http_credentials) => http_credentials(), + optional(:ignore_https_errors) => boolean(), + optional(:is_mobile) => boolean(), + optional(:javascript_enabled) => boolean(), + optional(:locale) => String.t(), + optional(:logger) => Playwright.Logger.t(), + optional(:offline) => boolean(), + optional(:permissions) => [String.t()], + optional(:proxy) => proxy_settings(), + optional(:record_har) => har_settings(), + optional(:record_video) => video_settings(), + optional(:reduced_motion) => motion_settings(), + optional(:screen) => screen_settings(), + optional(:service_workers) => worker_settings(), + optional(:storage_state) => storage_state(), + optional(:strict_selectors) => boolean(), + optional(:timezone_id) => String.t(), + optional(:user_agent) => String.t(), + optional(:video_size) => map(), + optional(:videos_path) => String.t(), + optional(:viewport) => viewport_settings() + } + + @typedoc "A client TLS certificate to be used in requests." + @type client_certificate :: %{ + required(:origin) => String.t(), + optional(:cert_path) => Path.t() | String.t(), + optional(:key_path) => Path.t() | String.t(), + optional(:pfx_path) => Path.t() | String.t(), + optional(:passphrase) => String.t() + } + + @typedoc """ + A value used to emulate "prefers-color-scheme" media feature. + + - `"light"` + - `"dark"` + - `"no-preference"` + - `nil` + """ + @type color_scheme :: String.t() | nil + + @typedoc "An HTTP cookie." + @type cookie :: %{ + optional(:name) => String.t(), + optional(:value) => String.t(), + required(:domain) => String.t(), + required(:path) => String.t(), + optional(:expires) => float(), + optional(:http_only) => boolean(), + optional(:secure) => boolean(), + # same_site: "Lax" | "None" | "Strict" + optional(:same_site) => String.t() + } + + @typedoc """ + A value used to emulate "forced-colors" media feature. + + - `"active"` + - `"none"` + - `nil` + """ + @type forced_colors :: String.t() | nil + + @typedoc "Geolocation emulation settings." + @type geolocation :: %{ + required(:latitude) => number(), + required(:longitude) => number(), + optional(:accuracy) => number() + } + + @typedoc """ + HAR recording settings. + + - `:content`: + - `"omit"` + - `"embed"` + - `"attach"` + - `:mode`: + - `"full"` + - `"minimal"` + """ + @type har_settings :: %{ + optional(:omit_content) => boolean(), + optional(:content) => String.t(), + required(:path) => String.t(), + optional(:mode) => String.t(), + optional(:url_filter) => String.t() | Regex.t() + } + + @typedoc "HTTP authetication credentials." + @type http_credentials :: %{ + required(:username) => String.t(), + required(:password) => String.t(), + optional(:origin) => String.t(), + optional(:send) => :always | :unauthorized + } + + @typedoc "A `map` containing additional HTTP headers to be sent with every request." + @type http_headers :: %{required(String.t()) => String.t()} + + @typedoc "Local storage settings." + @type local_storage :: %{ + required(:name) => String.t(), + required(:value) => String.t() + } + + @typedoc "Options for tracing API." + @type opts_tracing :: %{ + optional(:categories) => [String.t()], + optional(:path) => String.t(), + optional(:screenshots) => boolean() + } + + @typedoc "Network proxy settings." + @type proxy_settings :: %{ + required(:server) => String.t(), + optional(:bypass) => String.t(), + optional(:username) => String.t(), + optional(:password) => String.t() + } + + @typedoc """ + Settings for emulating "prefers-reduced-motion" media feature + + - `"reduce"` + - `"no-preference"` + - `nil` + """ + @type motion_settings :: %{ + required(:server) => String.t(), + optional(:bypass) => String.t(), + optional(:username) => String.t(), + optional(:password) => String.t() + } + + @typedoc "Window screen size settings." + @type screen_settings :: %{ + required(:width) => number(), + required(:height) => number() + } + + @typedoc "Storage state settings." + @type storage_state :: %{ + required(:cookies) => [cookie()], + required(:origins) => [ + %{ + required(:origin) => String.t(), + required(:local_storage) => [local_storage()] + } + ] + } + + @typedoc "Video recording settings." + @type video_settings :: %{ + required(:dir) => String.t(), + optional(:size) => %{ + width: number(), + height: number() + } + } + + @typedoc "Viewport settings." + @type viewport_settings :: %{ + required(:width) => number(), + required(:height) => number() + } + + @typedoc """ + Window screen size settings. + + - `"allow"` + - `"block"` + """ + @type worker_settings :: String.t() # callbacks # --------------------------------------------------------------------------- @@ -44,8 +500,22 @@ defmodule Playwright.Browser do # API # --------------------------------------------------------------------------- - # @spec browser_type(t()) :: BrowserType.t() - # def browser_type(browser) + @doc """ + Get the `Playwright.BrowserType` (as, `:chromium`, `:firefox` or `:webkit`) + to which this `Playwright.Browser` belongs. + + Usage + + Browser.browser_type(browser); + + Returns + + - `Playwright.BrowserType.t()` + """ + @spec browser_type(t()) :: BrowserType.t() + def browser_type(%Browser{} = browser) do + browser.parent + end @doc """ Closes the browser. @@ -57,18 +527,43 @@ defmodule Playwright.Browser do 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. + > #### NOTE {: .info} + > + > This is similar to force-quitting the browser. Therefore, be sure to call + > `Playwright.BrowserContext.close/1` on any `BrowserContext` instances + > explicitly created earlier by calling `Browser.new_context/1` before calling + > `Browser.close/2`. - ## Returns + The `Browser` instance itself is considered to be disposed and cannot be + used any longer. + + ## Usage + + Browser.close(browser) + Browser.close(browser, %{reason: "All done."}) + + ## Arguments + + | name | | description | + | --------- | ---------- | ----------------------- | + | `browser` | | The "subject" `Browser` | + | `options` | (optional) | `Browser.opts_close()` | + + ## Options - - `:ok` + | name | | description | + | --------- | ---------- | --------------------------------- | + | `:reason` | (optional) | The reason to be reported to any operations interrupted by the browser closure. | + + ## Returns + - `:ok` """ - def close(%Browser{session: session} = browser) do + @spec close(t(), opts_close()) :: :ok + def close(%Browser{session: session} = browser, options \\ %{}) do case Channel.find(session, {:guid, browser.guid}, %{timeout: 10}) do %Browser{} -> - Channel.post(session, {:guid, browser.guid}, :close) - :ok + Channel.close(browser, options) {:error, _} -> :ok @@ -76,39 +571,79 @@ defmodule Playwright.Browser do end @doc """ - Returns a list of all open browser contexts. In a newly created browser, - this will return zero browser contexts. + Returns a list of all open browser contexts. + + For a newly created `Playwright.Browser`, this will return zero contexts. - ## Example + ## Usage contexts = Browser.contexts(browser) - asset Enum.empty?(contexts) + assert Enum.empty?(contexts) Browser.new_context(browser) contexts = Browser.contexts(browser) assert length(contexts) == 1 + + ## Arguments + + | name | | description | + | --------- | ---------- | ----------------------- | + | `browser` | | The "subject" `Browser` | + + ## Returns + + - `[Playwright.BrowserContext.t()]` """ @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) + @doc """ + Returns a new `Playwright.CDPSession` instance. + + > #### NOTE {: .info} + > + > CDP Sessions are only supported on Chromium-based browsers. - # @spec new_browser_cdp_session(BrowserContext.t()) :: Playwright.CDPSession.t() - # def new_browser_cdp_session(browser) + ## Usage - # --- + sesssion = Browser.new_browser_cdp_session(browser) + + ## Arguments + + | name | | description | + | --------- | ---------- | ----------------------- | + | `browser` | | The "subject" `Browser` | + + ## Returns + + - `[Playwright.CDPSession.t()]` + - `{:error, %Error{}}` + """ + @pipe {:new_browser_cdp_session, [:browser]} + @spec new_browser_cdp_session(t()) :: CDPSession.t() | {:error, Error.t()} + def new_browser_cdp_session(browser) do + Channel.post({browser, "newBrowserCDPSession"}) + end @doc """ - Create a new `Playwright.BrowserContext` for this `Playwright.Browser`. + Creates 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 + > #### NOTE {: .info} + > + > If directly using this method to create `BrowserContext` instances, it is a + > best practice to explicitly close the returned context via + > `Playwright.BrowserContext.close/1` when your code is finished with the + > `BrowserContext` and before calling `Playwright.Browser.close/2`. + > This approach will ensure the context is closed gracefully and any artifacts + > (e.g., HARs and videos) are fully flushed and saved. + + ## Usage # create a new "incognito" browser context. context = Browser.new_context(browser) @@ -118,20 +653,27 @@ defmodule Playwright.Browser do Page.goto(page, "https://example.com") - ## Returns + ## Arguments - - `Playwright.BrowserContext.t()` + | name | | description | + | --------- | ---------- | ------------------------ | + | `browser` | | The "subject" `Browser` | + | `options` | (optional) | `Browser.opts_new()` | - ## Arguments + ## Options - | 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 | `...` | ... | + See "Shared options" above. + + ## Returns + + - `Playwright.BrowserContext.t()` + - `{:error, Error.t()}` """ - @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)) + @pipe {:new_context, [:browser]} + @pipe {:new_context, [:browser, :options]} + @spec new_context(t(), opts_new()) :: BrowserContext.t() | {:error, Error.t()} + def new_context(%Browser{} = browser, options \\ %{}) do + Channel.post({browser, :new_context}, prepare(options)) end @doc """ @@ -148,43 +690,131 @@ defmodule Playwright.Browser do explicitly create via `Playwright.Browser.new_context/2` followed by `Playwright.BrowserContext.new_page/2`, given the new context, to manage resource lifecycles. + + ## Usage + + Browser.new_page(browser) + Browser.new_page(browser, options) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------ | + | `browser` | | The "subject" `Browser` | + | `options` | (optional) | `Browser.opts_new()` | + + ## Options + + See "Shared options" above. + + ## Returns + + - `Playwright.Page.t()` + - `{:error, Error.t()}` """ - @spec new_page(t(), options()) :: Page.t() + @spec new_page(t(), opts_new()) :: {Page.t() | {:error, Error.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) - # TODO: handle the following, for `page`: - # ** (KeyError) key :guid not found in: {:error, %Playwright.Channel.Error{message: "Target closed"}} + case BrowserContext.new_page(context) do + {:error, _} = error -> + error - # establish co-dependency - Channel.patch(session, {:guid, context.guid}, %{owner_page: page}) - Channel.patch(session, {:guid, page.guid}, %{owned_context: context}) + page -> + # establish co-dependency + Channel.patch(session, {:guid, context.guid}, %{owner_page: page}) + Channel.patch(session, {:guid, page.guid}, %{owned_context: context}) + end end - # --- + @doc """ + `Playwright.Browser.start_tracing/3` initiates a Chromium Tracing session. - # test_chromium_tracing.py - # @spec start_tracing(t(), Page.t(), options()) :: :ok - # def start_tracing(browser, page \\ nil, options \\ %{}) + `start_tracing/3` may be used in conjunction with `Playwright.Browser.stop_tracing/1` + to create a trace file that can be opened in the Chrome DevTools performance + panel. - # test_chromium_tracing.py - # @spec stop_tracing(t()) :: binary() - # def stop_tracing(browser) + > #### NOTE {: .info} + > + > This API controls [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) + > which is a low-level Chromium-specific debugging tool. Details on the API + > used to work with [Playwright Tracing](https://playwright.dev/docs/trace-viewer) + > are found in the `Playwright.Tracing` API documentation. - # @spec version(BrowserContext.t()) :: binary - # def version(browser) + ## Usage - # --- + browser = Browser.start_tracing(browser) + browser = Browser.start_tracing(browser, %{path: "trace.json"}) + browser = Browser.start_tracing(browser, page) + browser = Browser.start_tracing(browser, page, %{path: "trace.json"}) - # events - # ---------------------------------------------------------------------------- + Page.goto(page, "https://example.com") + + Browser.stop_tracing(browser) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------ | + | `browser` | | The "subject" `Browser` | + | `page` | (optional) | If specified, tracing includes screenshots of the given page. | + | `options` | (optional) | `Browser.opts_tracing()` | - # test_browsertype_connect.py - # @spec on(t(), event(), function()) :: Browser.t() - # def on(browser, event, callback) + ## Options + + | name | | description | + | -------------- | ---------- | --------------------------------- | + | `:categories` | (optional) | Specifies custom categories to use instead of defaults. | + | `:path` | (optional) | Provides a path to write the trace file. | + | `:screenshots` | (optional) | Indicates whether to capture screenshots in the trace. | + + ## Returns + + - `Playwright.Browser.t()` + - `{:error, Error.t()}` + """ + @pipe {:start_tracing, [:browser]} + @pipe {:start_tracing, [:browser, :page]} + @pipe {:start_tracing, [:browser, :page, :options]} + @spec start_tracing(t(), Page.t(), opts_tracing()) :: t() | {:error, Error.t()} + def start_tracing(browser, page \\ nil, options \\ %{}) + + def start_tracing(%Browser{} = browser, _page, _options) do + Channel.post({browser, :start_tracing}) + end + + @doc """ + `Playwright.Browser.stop_tracing/3` terminates a Chromium Tracing session. + + > #### NOTE {: .info} + > + > This API controls [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) + > which is a low-level Chromium-specific debugging tool. Details on the API + > used to work with [Playwright Tracing](https://playwright.dev/docs/trace-viewer) + > are found in the `Playwright.Tracing` API documentation. + + ## Usage + + Browser.stop_tracing(browser) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------ | + | `browser` | | The "subject" `Browser` | + + ## Returns + + - `Playwright.Artifact.t()` + - `{:error, Error.t()}` + """ + @pipe {:stop_tracing, [:browser]} + @spec stop_tracing(t()) :: Playwright.Artifact.t() | {:error, Error.t()} + def stop_tracing(%Browser{} = browser) do + Channel.post({browser, :stop_tracing}) + end # private # ---------------------------------------------------------------------------- diff --git a/lib/playwright/browser_context.ex b/lib/playwright/browser_context.ex index 4324ef5b..bf37ee38 100644 --- a/lib/playwright/browser_context.ex +++ b/lib/playwright/browser_context.ex @@ -161,6 +161,7 @@ defmodule Playwright.BrowserContext do use Playwright.SDK.ChannelOwner alias Playwright.{BrowserContext, Frame, Page} + alias Playwright.API.Error alias Playwright.SDK.{Channel, ChannelOwner, Helpers} @property :bindings @@ -168,17 +169,17 @@ defmodule Playwright.BrowserContext do @property :owner_page @property :routes - @typedoc "Recognized cookie fields" + @typedoc "An HTTP cookie." @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() + optional(:name) => String.t(), + optional(:value) => String.t(), + required(:domain) => String.t(), + required(:path) => String.t(), + optional(:expires) => float(), + optional(:http_only) => boolean(), + optional(:secure) => boolean(), + # same_site: "Lax" | "None" | "Strict" + optional(:same_site) => String.t() } @typedoc "Supported events" @@ -195,9 +196,75 @@ defmodule Playwright.BrowserContext do @typedoc "An optional (maybe nil) function or option" @type function_or_options :: fun() | options() | nil - @typedoc "A map/struct providing call options" + @typedoc "Geolocation emulation settings." + @type geolocation :: %{ + required(:latitude) => number(), + required(:longitude) => number(), + optional(:accuracy) => number() + } + + @typedoc "Local storage settings." + @type local_storage :: %{ + required(:name) => String.t(), + required(:value) => String.t() + } + + @typedoc "A map/struct providing generic call options" @type options :: map() + @typedoc "Options for calls to `clear_cookies/2`" + @type opts_clear_cookies :: %{ + optional(:domain) => String.t() | Regex.t(), + optional(:name) => String.t() | Regex.t(), + optional(:path) => String.t() | Regex.t() + } + + @typedoc "Options for `close/2`." + @type opts_close :: %{ + optional(:reason) => String.t() + } + + @typedoc "Options for `grant_permissions/3`." + @type opts_permissions :: %{ + optional(:origin) => String.t() + } + + @typedoc "Options for `route/4`" + @type opts_route :: %{ + optional(:times) => number() + } + + @typedoc "Options for `storage_state/2`." + @type opts_storage :: %{ + optional(:path) => String.t() + } + + @typedoc "A permission available for `grant_permissions/3`." + @type permission :: String.t() | atom() + + @typedoc "A route matcher for `route/4" + @type route_url :: String.t() | Regex.t() | function() + + @typedoc "JavaScript provided as a filesystem path, or as script content." + @type script :: + %{ + optional(:content) => String.t(), + optional(:path) => String.t() + } + | function() + | String.t() + + @typedoc "Storage state settings." + @type storage_state :: %{ + required(:cookies) => [cookie()], + required(:origins) => [ + %{ + required(:origin) => String.t(), + required(:local_storage) => [local_storage()] + } + ] + } + @typedoc "A string URL" @type url :: String.t() @@ -227,33 +294,35 @@ defmodule Playwright.BrowserContext do All pages within this context will have these cookies installed. Cookies can be obtained via `Playwright.BrowserContext.cookies/1`. - ## Returns + ## Usage - - `:ok` + BrowserContext.add_cookies(context, [cookie_1, cookie_2]) - ## Example + ## Cookie settings - :ok = BrowserContext.add_cookies(context, [cookie_1, cookie_2]) - - ## Cookie fields - - | key | type | description | - | ---------- | ----------- | ----------- | - | `:name` | `binary()` | | - | `:value` | `binary()` | | - | `:url` | `binary()` | *(optional)* either url or domain / path are required | - | `:domain` | `binary()` | *(optional)* either url or domain / path are required | - | `:path` | `binary()` | *(optional)* either url or domain / path are required | - | `:expires` | `float()` | *(optional)* Unix time in seconds. | - | `:httpOnly` | `boolean()` | *(optional)* | - | `:secure` | `boolean()` | *(optional)* | - | `:sameSite` | `binary()` | *(optional)* one of "Strict", "Lax", "None" | + | name | | description | + | ------------ | ----------- | ----------- | + | `:name` | optional() | | + | `:value` | optional() | | + | `:url` | optional() | One of `:url` or `:domain` / `:path` are required. | + | `:domain` | optional() | One of `:url` or `:domain` / `:path` are required. For the cookie to apply to all subdomains as well, prefix `:domain` with a dot, like so: `".example.com"`. | + | `:path` | optional() | One of `:url` or `:domain` / `:path` are required. | + | `:expires` | optional() | Unix time in seconds. | + | `:http_only` | optional()` | | + | `:secure` | optional()` | | + | `:same_site` | optional() | One of "Strict", "Lax", "None" | + + ## Returns + + - `Playwright.BrowserContext.t()` + - `{:error, Playwright.API.Error.t()}` """ - @spec add_cookies(t(), [cookie]) :: :ok + @pipe {:add_cookies, [:context, :cookies]} + @spec add_cookies(t(), [cookie]) :: t() | {:error, Error.t()} def add_cookies(context, cookies) - def add_cookies(%BrowserContext{session: session} = context, cookies) do - Channel.post(session, {:guid, context.guid}, :add_cookies, %{cookies: cookies}) + def add_cookies(%BrowserContext{} = context, cookies) do + Channel.post({context, :add_cookies}, %{cookies: cookies}) end @doc """ @@ -270,90 +339,162 @@ defmodule Playwright.BrowserContext do scripts are run. This is useful to amend the JavaScript environment, e.g. to seed `Math.random`. - ## Returns - - - `:ok` - - ## Arguments - - | key/name | type | | description | - | ----------- | ------ | --------------------- | ----------- | - | `script` | param | `binary()` or `map()` | As `binary()`: an inlined script to be evaluated; As `%{path: path}`: a path to a JavaScript file. | - - ## Example + ## Usage Overriding `Math.random` before the page loads: # preload.js Math.random = () => 42; + # Playwright script BrowserContext.add_init_script(context, %{path: "preload.js"}) - ## Notes - - > While the official Node.js Playwright implementation supports an optional - > `param: arg` for this function, the official Python implementation does - > not. This implementation matches the Python for now. - + > #### NOTE {: .info} + > > The order of evaluation of multiple scripts installed via > `Playwright.BrowserContext.add_init_script/2` and > `Playwright.Page.add_init_script/2` is not defined. - """ - @spec add_init_script(t(), binary() | map()) :: :ok - def add_init_script(%BrowserContext{session: session} = context, script) when is_binary(script) do - params = %{source: script} - case Channel.post(session, {:guid, context.guid}, :add_init_script, params) do - {:ok, _} -> - :ok + ## Arguments - {:error, error} -> - {:error, error} - end + | name | | description | + | ----------- | ---------- | ----------- | + | `script` | | `script()` | + | `arg` | (optional) | An optional argument to be passed to the `:script` (only supported when `:script` is a `function()`). | + + ### Script details + + The `:script` argument may be provided as follows: + + - As `function()`, is an Elixir callback. This mechanism supports an optional + `:arg` to be passed to the script at evaluation. + - As a `String.t()`, is raw script content to be evaluated. + - As a `map()`, one of the following: + - `:content` - Raw script content to be evaluated. + - `:path` - A path to a JavaScript file. If `:path` is a relative path, it + is resolved to the current working directory. + + ## Returns + + - `Playwright.BrowserContext.t()` + - `{:error, Playwright.API.Error.t()}` + """ + @pipe {:add_init_script, [:context, :script]} + @spec add_init_script(t(), script()) :: t() | {:error, Error.t()} + def add_init_script(%BrowserContext{} = context, script) when is_binary(script) do + Channel.post({context, :add_init_script}, %{source: script}) end def add_init_script(%BrowserContext{} = context, %{path: path} = script) when is_map(script) do add_init_script(context, File.read!(path)) end - # --- - # @spec background_pages(t()) :: [Playwright.Page.t()] - # def background_pages(context) + # def background_pages(%BrowserContext{} = context) - # @spec browser(t()) :: Playwright.Browser.t() - # def browser(context) + @doc """ + Clears `Playwright.BrowserContext` cookies. Accepts an optional filter. - # --- + ## Usage - @doc """ - Clears `Playwright.BrowserContext` cookies. + BrowserContext.clear_cookies(context) + BrowserContext.clear_cookies(context, %{name: "session-id"}) + BrowserContext.clear_cookies(context, %{domain: "example.com"}) + BrowserContext.clear_cookies(context, %{domain: ~r/.*example\.com/}) + BrowserContext.clear_cookies(context, %{path: "/api/v1"}) + BrowserContext.clear_cookies(context, %{name: "session-id", domain: "example.com"}) + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `BrowserContext` | + | `options` | (optional) | Options (see below) | + + ### Options + + | name | | description | + | -------- | ---------- | --------------------------------- | + | `domain` | (optional) | Filters to only remove cookies with the given domain. | + | `name` | (optional) | Filters to only remove cookies with the given name. | + | `path` | (optional) | Filters to only remove cookies with the given path. | + + ## Returns + + - `Playwright.BrowserContext.t()` + - `{:error, Playwright.API.Error.t()}` """ - @spec clear_cookies(t()) :: :ok - def clear_cookies(%BrowserContext{session: session} = context) do - Channel.post(session, {:guid, context.guid}, :clear_cookies) + @pipe {:clear_cookies, [:context]} + @pipe {:clear_cookies, [:context, :options]} + @spec clear_cookies(t(), opts_clear_cookies()) :: t() | {:error, Error.t()} + def clear_cookies(context, options \\ %{}) + + def clear_cookies(%BrowserContext{} = context, options) do + Channel.post({context, :clear_cookies}, options) end - @spec clear_permissions(t()) :: :ok - def clear_permissions(%BrowserContext{session: session} = context) do - Channel.post(session, {:guid, context.guid}, :clear_permissions) + @doc """ + Clears all permission overrides for the `Playwright.BrowserContext`. + + ## Usage + + BrowserContext.grant_permissions(context, ["clipboard-read"]) + BrowserContext.clear_permissions(context) + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `BrowserContext` | + + ## Returns + + - `Playwright.BrowserContext.t()` + - `{:error, Playwright.API.Error.t()}` + """ + @pipe {:clear_permissions, [:context]} + @spec clear_permissions(t()) :: t() | {:error, Error.t()} + def clear_permissions(%BrowserContext{} = context) do + Channel.post({context, :clear_permissions}) end @doc """ Closes the `Playwright.BrowserContext`. All pages that belong to the - `Playwright.BrowserContext` will be closed. + context will be closed. - > NOTE: - > - The default browser context cannot be closed. + > #### NOTE {: .info} + > + > The default browser context cannot be closed. + + ## Usage + + BrowserContext.close(context) + BrowserContext.close(context, %{reason: "All done"}) + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `BrowserContext` | + | `options` | (optional) | Options (see below) | + + ### Options + + | name | | description | + | -------- | ---------- | --------------------------------- | + | `reason` | (optional) | The reason to be reported to any operations interrupted by the context disposal. | + + ## Returns + + - `:ok` """ - @spec close(t()) :: :ok - def close(%BrowserContext{session: session} = context) do + @spec close(t(), opts_close()) :: :ok + def close(%BrowserContext{} = context, options \\ %{}) do # A call to `close` will remove the item from the catalog. `Catalog.find` # here ensures that we do not `post` a 2nd `close`. - case Channel.find(session, {:guid, context.guid}, %{timeout: 10}) do + case Channel.find(context.session, {:guid, context.guid}, %{timeout: 10}) do %BrowserContext{} -> - Channel.post(session, {:guid, context.guid}, :close) - :ok + Channel.close(context, options) {:error, _} -> :ok @@ -366,19 +507,27 @@ defmodule Playwright.BrowserContext do If no URLs are specified, this method returns all cookies. If URLs are specified, only cookies that affect those URLs are returned. - ## Returns + ## Usage - - `[cookie()]` See `add_cookies/2` for cookie field details. + BrowserContext.cookies(context) + BrowserContext.cookies(context, "https://example.com") + BrowserContext.cookies(context, ["https://example.com"]) ## Arguments - | key/name | type | | description | - | ---------- | ----- | -------------------------- | ----------- | - | `urls` | param | `binary()` or `[binary()]` | List of URLs. `(optional)` | + | name | | description | + | ---------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `urls` | (optional) | A list of URLs. | + + ## Returns + + - `[cookie()]` See `add_cookies/2` for cookie field details. + - `{:error, Playwright.API.Error.t()}` """ - @spec cookies(t(), url | [url]) :: [cookie] - def cookies(%BrowserContext{session: session} = context, urls \\ []) do - Channel.post(session, {:guid, context.guid}, :cookies, %{urls: urls}) + @spec cookies(t(), url() | [url()]) :: [cookie()] | {:error, Error.t()} + def cookies(%BrowserContext{} = context, urls \\ []) do + Channel.post({context, :cookies}, %{urls: urls}) end @doc """ @@ -405,6 +554,8 @@ defmodule Playwright.BrowserContext do BrowserContext.new_page(context) end) """ + @doc deprecated: "This function will be removed in favor of `BrowserContext.on/3`." + @spec expect_event(t(), event(), options(), function()) :: Playwright.SDK.Channel.Event.t() | {:error, Error.t()} def expect_event(context, event, options \\ %{}, trigger \\ nil) def expect_event(%BrowserContext{session: session} = context, event, options, trigger) do @@ -430,17 +581,16 @@ defmodule Playwright.BrowserContext do (30 seconds). Pass 0 to disable timeout. The default value can be changed via `Playwright.BrowserContext.set_default_timeout/2`. """ - # Temporarily disable spec: - # @spec expect_page(t(), map(), function()) :: Playwright.SDK.Channel.Event.t() - def expect_page(context, options \\ %{}, trigger \\ nil) do + @doc deprecated: "This function will be removed in favor of `BrowserContext.on/3`." + def expect_page(%BrowserContext{} = context, options \\ %{}, trigger \\ nil) do expect_event(context, :page, options, trigger) end @doc """ - Adds a function called `param:name` on the `window` object of every frame in + Adds a function called `name` on the `window` object of every frame in every page in the context. - When called, the function executes `param:callback` and resolves to the return + When evaluated, the function executes `callback` and resolves to the return value of the `callback`. The first argument to the `callback` function includes the following details @@ -452,61 +602,222 @@ defmodule Playwright.BrowserContext do page: %Playwright.Page{} } - See `Playwright.Page.expose_binding/4` for a similar, page-scoped version. + See `Playwright.Page.expose_binding/4` for a similar, Page-scoped version. + + ## Usage + + An example of exposing a page URL to all frames in all pages in the context: + + BrowserContext.expose_binding(context, "pageURL", fn %{page: page} -> + Page.url(page) + end) + + BrowserContext.new_page(context) + |> Page.set_content(\"\"\" + + +
+ \"\"\") + |> Page.get_by_role("button") + |> Page.click() + + ## Arguments + + | name | | description | + | ---------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `name` | | Name of the function on the `window` object. | + | `callback` | | Callback function that will be evaluated. | + + ## Returns + + - `Playwright.BrowserContext.t()` + - `{:error, Playwright.API.Error.t()}` """ - @spec expose_binding(BrowserContext.t(), String.t(), function(), options()) :: BrowserContext.t() - def expose_binding(%BrowserContext{session: session} = context, name, callback, options \\ %{}) do + @pipe {:expose_binding, [:context, :name, :callback]} + @spec expose_binding(BrowserContext.t(), String.t(), function()) :: t() | {:error, Error.t()} + def expose_binding(%BrowserContext{session: session} = context, name, callback) do Channel.patch(session, {:guid, context.guid}, %{bindings: Map.merge(context.bindings, %{name => callback})}) - post!(context, :expose_binding, Map.merge(%{name: name, needs_handle: false}, options)) + Channel.post({context, :expose_binding}, %{name: name, needs_handle: false}) end @doc """ - Adds a function called `param:name` on the `window` object of every frame in + Adds a function called `name` on the `window` object of every frame in every page in the context. - When called, the function executes `param:callback` and resolves to the return + When evaluated, the function executes `callback` and resolves to the return value of the `callback`. See `Playwright.Page.expose_function/3` for a similar, Page-scoped version. + + ## Usage + + An example of adding a `sha256` function all pages in the context: + + BrowserContext.expose_function(context, "sha256", fn text -> + :crypto.hash(:sha256, text) + |> Base.encode16() + |> String.downcase() + end) + + BrowserContext.new_page(context) + |> Page.set_content(\"\"\" + + +
+ \"\"\") + |> Page.get_by_role("button") + |> Page.click() + + ## Arguments + + | name | | description | + | ---------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `name` | | Name of the function on the `window` object. | + | `callback` | | Callback function that will be evaluated. | + + ## Returns + + - `Playwright.BrowserContext.t()` + - `{:error, Playwright.API.Error.t()}` """ - @spec expose_function(BrowserContext.t(), String.t(), function()) :: BrowserContext.t() - def expose_function(context, name, callback) do + @pipe {:expose_function, [:context, :name, :callback]} + @spec expose_function(BrowserContext.t(), String.t(), function()) :: t() | {:error, Error.t()} + def expose_function(%BrowserContext{} = context, name, callback) do expose_binding(context, name, fn _, args -> callback.(args) end) end - @spec grant_permissions(t(), [String.t()], options()) :: :ok | {:error, Channel.Error.t()} - def grant_permissions(%BrowserContext{session: session} = context, permissions, options \\ %{}) do - params = Map.merge(%{permissions: permissions}, options) - Channel.post(session, {:guid, context.guid}, :grant_permissions, params) + @doc """ + Grants the specified permissions to the browser context. + + If the optional `origin` is provided, only grants the corresponding + permissions to that origin. + + ## Usage + + BrowserContext.grant_permissions(context, ["geolocation"]) + BrowserContext.grant_permissions(context, ["geolocation"], %{origin: "https://example.com"}) + + ## Arguments + + | name | | description | + | ------------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `permissions` | | A permission or list of permissions to grant. | + | `options` | (optional) | Options (see below) | + + ### Available permisions + + Permissions may be any of the following: + + - `'accelerometer'` + - `'accessibility-events'` + - `'ambient-light-sensor'` + - `'background-sync'` + - `'camera'` + - `'clipboard-read'` + - `'clipboard-write'` + - `'geolocation'` + - `'gyroscope'` + - `'magnetometer'` + - `'microphone'` + - `'midi'` + - `'midi-sysex'` (system-exlusive midi) + - `'notifications'` + - `'payment-handler'` + - `'storage-access'` + + ### Options + + | name | | description | + | -------- | ---------- | --------------------------------- | + | `origin` | (optional) | The [origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin) to which to scope the granted permissions. e.g., "https://example.com" | + + ## Returns + + - `Playwright.BrowserContext.t()` + - `{:error, Playwright.API.Error.t()}` + """ + @pipe {:grant_permissions, [:context, :permissions]} + @pipe {:grant_permissions, [:context, :permissions, :options]} + @spec grant_permissions(t(), permission() | [permission()], opts_permissions()) :: t() | {:error, Playwright.API.Error.t()} + def grant_permissions(%BrowserContext{} = context, permissions, options \\ %{}) do + Channel.post({context, :grant_permissions}, %{permissions: List.flatten([permissions])}, options) end - @spec new_cdp_session(t(), Frame.t() | Page.t()) :: Playwright.CDPSession.t() - def new_cdp_session(context, owner) + @doc """ + Returns a newly created Chrome DevTools Protocol (CDP) session. + + > #### NOTE {: .info} + > + > CDP sessions are only supported in Chromium-based browsers. + + ## Usage + + page = BrowserContext.new_page(context) + BrowserContext.new_cdp_session(context, page) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `target` | | Target for which to create the new CDP session. May be a `Playwright.Page` or a `Playwright.Frame` | - def new_cdp_session(%BrowserContext{session: session} = context, %Frame{} = frame) do - Channel.post(session, {:guid, context.guid}, "newCDPSession", %{frame: %{guid: frame.guid}}) + ## Returns + + - `Playwright.CDPSession.t()` + - `{:error, Playwright.API.Error.t()}` + """ + @pipe {:new_cdp_session, [:context, :target]} + @spec new_cdp_session(t(), Frame.t() | Page.t()) :: Playwright.CDPSession.t() | {:error, Error.t()} + def new_cdp_session(context, target) + + def new_cdp_session(%BrowserContext{} = context, %Frame{} = frame) do + Channel.post({context, "newCDPSession"}, %{frame: %{guid: frame.guid}}) end - def new_cdp_session(%BrowserContext{session: session} = context, %Page{} = page) do - Channel.post(session, {:guid, context.guid}, "newCDPSession", %{page: %{guid: page.guid}}) + def new_cdp_session(%BrowserContext{} = context, %Page{} = page) do + Channel.post({context, "newCDPSession"}, %{page: %{guid: page.guid}}) end @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. + ## Usage + + BrowserContext.new_page(context) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + + ## Returns + + - `Playwright.Page.t()` + - `{:error, Playwright.API.Error.t()}` """ - @spec new_page(t()) :: Page.t() + @pipe {:new_page, [:context]} + @spec new_page(t()) :: Page.t() | {:error, Error.t()} def new_page(context) - def new_page(%BrowserContext{session: session} = context) do + def new_page(%BrowserContext{} = context) do case context.owner_page do nil -> - Channel.post(session, {:guid, context.guid}, :new_page) + Channel.post({context, :new_page}) %Playwright.Page{} -> raise(RuntimeError, message: "Please use Playwright.Browser.new_context/1") @@ -516,24 +827,105 @@ defmodule Playwright.BrowserContext do @doc """ Register a (non-blocking) callback/handler for various types of events. """ - @spec on(t(), event(), function()) :: :ok - def on(%BrowserContext{session: session} = context, event, callback) do - Channel.bind(session, {:guid, context.guid}, event, callback) + @spec on(t(), event(), function()) :: t() + def on(%BrowserContext{} = context, event, callback) do + bind!(context, event, callback) end @doc """ Returns all open pages in the context. + ## Usage + + BrowserContext.pages(context) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + ## Returns - - `[Page.t()]` + - `[Page.t()]` """ @spec pages(t()) :: [Page.t()] def pages(%BrowserContext{} = context) do Channel.list(context.session, {:guid, context.guid}, "Page") end - @spec route(t(), binary(), function(), map()) :: :ok + @doc """ + Routing provides the capability of modifying network requests that are + initiated by any page in the browser context. + + Once a route is enabled, every request matching the URL pattern will stall + unless it is continued, fulfilled, or aborted. + + Page routes (set up with `Page.route4`) take precedence over browser context + routes when the request matches both handlers. + + To remove a route with its handler, use `Playwright.BrowserContext.unroute/3`. + + > #### NOTE {: .info} + > + > `Playwright.BrowserContext.route/4` will not intercept requets intercepted + > by a Service Worker. See [GitHub issue 1010](https://github.com/microsoft/playwright/issues/1090). + > It is recommended to disable Service Workers when using request interception + > by setting `:service_workers` to `'block'` when creating a `BrowserContext`. + + > #### NOTE {: .info} + > + > Enabling routing disables http caching. + + ## Usage + + An example of a naïve handler that aborts all image requests: + + Browser.new_context(browser) + |> BrowserContext.route("**/*.{png,jpg,jpeg}", fn route -> Route.abort(route) end) + |> BrowserContext.new_page() + |> Page.goto("https://example.com") + + Browser.close(browser) + + An example of examining the request to decide on the route action. For + example, mocking all requests that contain some post data, and leaving + all other requests un-modified. + + Browser.new_context(browser) + |> BrowserContext.route("/api/**", fn route -> + case Route.request(route) |> Request.post_data() |> Enum.fetch("some-data") do + {:ok, _} -> + Route.fulfill(route, %{body: "mock-data"}) + + _ -> + Route.continue(route) + end + end) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `url` | | A glob pattern, regex pattern, or predicate receiving a [URL](https://nodejs.org/api/url.html) to match against while routing. When a `:base_url` was provided via the context options, and the provided URL is a path, the two are merged. | + | `handler` | | The handler function to manage request routing. | + | `options` | (optional) | Options (see below). | + + ### Options + + | name | | description | + | -------- | ---------- | --------------------------------- | + | `times` | (optional) | How many times a route should be used. Defaults to every time. | + + ## Returns + + - `BrowserContext.t()` + - `{:error, Error.t()}` + """ + @pipe {:route, [:context, :pattern, :handler]} + @pipe {:route, [:context, :pattern, :handler, :options]} + @spec route(t(), route_url(), function(), opts_route()) :: t() | {:error, Error.t()} def route(context, pattern, handler, options \\ %{}) def route(%BrowserContext{session: session} = context, pattern, handler, _options) do @@ -545,55 +937,269 @@ defmodule Playwright.BrowserContext do patterns = Helpers.RouteHandler.prepare(routes) Channel.patch(session, {:guid, context.guid}, %{routes: routes}) - Channel.post(session, {:guid, context.guid}, :set_network_interception_patterns, %{patterns: patterns}) + Channel.post({context, :set_network_interception_patterns}, %{patterns: patterns}) end) end # --- - # @spec route_from_har(t(), binary(), map()) :: :ok - # def route(context, har, options \\ %{}) + # @spec route_from_har(t(), binary(), map()) :: t() | {:error, Error.t()} + # def route_from_har(context, har, options \\ %{}) # ??? # @spec service_workers(t()) :: [Playwright.Worker.t()] # def service_workers(context) - # test_navigation.py - # @spec set_default_navigation_timeout(t(), number()) :: :ok - # def set_default_navigation_timeout(context, timeout) + @doc """ + Changes the default maximum navigation time for the following calls and + related shortcuts: - # test_navigation.py - # @spec set_default_timeout(t(), number()) :: :ok - # def set_default_timeout(context, timeout) + - `Playwright.Page.go_back/2` + - `Playwright.Page.go_forward/2` + - `Playwright.Page.goto/2` + - `Playwright.Page.reload/2` + - `Playwright.Page.set_content/3` - # test_interception.py - # test_network.py - # @spec set_extra_http_headers(t(), headers()) :: :ok - # def set_extra_http_headers(context, headers) + ## Usage - # test_geolocation.py - # @spec set_geolocation(t(), geolocation()) :: :ok - # def set_geolocation(context, geolocation) + BrowserContext.set_default_navigation_timeout(context, 1_000) - # ??? - # @spec set_http_credentials(t(), http_credentials()) :: :ok - # def set_http_credentials(context, http_credentials) + ## Arguments - # --- + | name | | description | + | --------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `timeout` | | Maximum navigation time in milliseconds. | - @spec set_offline(t(), boolean()) :: :ok - def set_offline(%BrowserContext{session: session} = context, offline) do - Channel.post(session, {:guid, context.guid}, :set_offline, %{offline: offline}) + ## Returns + + - `BrowserContext.t()` + - `{:error, Error. t()}` + """ + @pipe {:set_default_navigation_timeout, [:context, :timeout]} + @spec set_default_navigation_timeout(t(), number()) :: t() | {:error, Error.t()} + def set_default_navigation_timeout(%BrowserContext{} = context, timeout) do + Channel.post({context, :set_default_navigation_timeout_no_reply}, %{timeout: timeout}) end - # --- + @doc """ + Changes the default maximum time for the following calls that accept a + `:timeout` option. - # @spec storage_state(t(), String.t()) :: {:ok, storage_state()} - # def storage_state(context, path \\ nil) + > #### NOTE {: .info} + > + > The following take precedence over this setting: + > + > - `Playwright.Page.set_default_navigation_timeout/2` + > - `Playwright.Page.set_default_timeout/2` + > - `Playwright.BrowserContext.set_default_navigation_timeout/2` - # --- + ## Usage + + BrowserContext.set_default_timeout(context, 1_000) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `timeout` | | Maximum navigation time in milliseconds. | + + ## Returns + + - `BrowserContext.t()` + - `{:error, Error. t()}` + """ + @pipe {:set_default_timeout, [:context, :timeout]} + @spec set_default_timeout(t(), number()) :: t() | {:error, Error.t()} + def set_default_timeout(%BrowserContext{} = context, timeout) do + Channel.post({context, :set_default_timeout_no_reply}, %{timeout: timeout}) + end + + @doc """ + Configures extra HTTP headers to be sent with every request initiated by any + page in the context. + + The headers are merged with page-specific extra HTTP headers set with + `Playwright.Page.set_extra_http_headers/2`. If page overrides a particular + header, the page-specific header value will be used instead of that from + the browser context. + + > #### NOTE {: .info} + > + > `Playwright.BrowserContext.set_extra_http_headers/2` does not guarantee + > the order of hedaers in the outgoing requests. + + ## Usage + + BrowserContext.set_extra_http_headers(context, %{referer: "https://example.com"}) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `headers` | | A `map()` containing additional HTTP headers to be sent with every request. All header values must be `String.t()`. | + + ## Returns + + - `BrowserContext.t()` + - `{:error, Error. t()}` + """ + @pipe {:set_extra_http_headers, [:context, :headers]} + @spec set_extra_http_headers(t(), map()) :: t() | {:error, Error.t()} + def set_extra_http_headers(%BrowserContext{} = context, headers) do + Channel.post({context, "setExtraHTTPHeaders"}, %{headers: serialize_headers(headers)}) + end + + @doc """ + Sets the context's geolocation. + + Passing `nil` emulates position unavailable. + + > #### NOTE {: .info} + > + > Consider using `Playwright.BrowserContext.grant_permissions/3` to grant + > permissions for the browser context pages to read geolocation. + + > #### WARNING! {: .warning} + > + > As of 2024-10-09, this function has not yet been successfully tested. + > So far, the test runs have failed to receive location data and instead + > experiences "error code 2", which [reportedly](https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError/code) + > represents `POSITION_UNAVAILABLE` - "The acquisition of the geolocation + > failed because one or several internal sources of position returned an internal error." + + ## Usage + + BrowserContext.set_geolocation(context, %{ + latitude: 59.95, + longitude: 30.31667 + }) + + ## Arguments + + | name | | description | + | ------------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `geolocation` | | `BrowserContext.geolocation()`. | + + ### Geolocation settings + + | name | | description | + | ----------- | ---------- | ----------------------------------- | + | `latitude` | | Latitude between `-90` and `90`. | + | `lingitude` | | Longitude between `-180` and `180`. | + | `accuracy` | (optional) | Non-negative accuracy value. Defaults to `0`. | + + ## Returns + + - `BrowserContext.t()` + - `{:error, Error. t()}` + """ + @pipe {:set_geolocation, [:context, :geolocation]} + @spec set_geolocation(t(), geolocation() | nil) :: t() | {:error, Error.t()} + def set_geolocation(context, params \\ nil) + + def set_geolocation(%BrowserContext{} = context, params) when is_map(params) do + Channel.post({context, :set_geolocation}, params) + end + + def set_geolocation(%BrowserContext{} = context, nil) do + Channel.post({context, :set_geolocation}) + end + + @doc """ + Configures whether the browser context should emulate being offline. + + ## Usage + + BrowserContext.set_offline(context, true) + BrowserContext.set_offline(context, false) + + ## Arguments + + | name | | description | + | --------- | ---------- | ------------------------------- | + | `context` | | The "subject" `BrowserContext`. | + | `offline` | | Whether to emulate the network being offline. | + + ## Returns + + - `BrowserContext.t()` + - `{:error, Error. t()}` + """ + @pipe {:set_offline, [:context, :offline]} + @spec set_offline(t(), boolean()) :: t() | {:error, Error.t()} + def set_offline(%BrowserContext{} = context, offline) do + Channel.post({context, :set_offline}, %{offline: offline}) + end + + @doc """ + Returns storage state for this browser context. + + The storage state contains current cookies and a local storage snapshot. + + ## Arguments + + | name | | description | + | ---------------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `options` | (optional) | Options (see below) | + + ### Options + + | name | | description | + | -------- | ---------- | --------------------------------- | + | `path` | (optional) | The file path to save the storage state. If path is a relative path, then it is resolved relative to current working directory. If no path is provided, storage state is still returned, but won't be saved to the disk. | + + ## Returns + + - `storage_state()` + - `{:error, Error.t()}` + """ + @spec storage_state(t(), opts_storage()) :: storage_state() | {:error, Error.t()} + def storage_state(%BrowserContext{} = context, options \\ %{}) do + {path, options} = Map.pop(options, :path) + + case Channel.post({context, :storage_state}, options) do + {:error, _} = error -> + error + + result -> + result = Map.new(result) + path && File.write!(path, Jason.encode!(result)) + result + end + end + + @doc """ + Removes a route created via `Playwright.BrowserContext.route/4`. + + When `handler` is not specified, removes all routes for the provided + URL/pattern. + + ## Usage + + BrowserContext.unroute(context, "**/*") + BrowserContext.unroute(context, "**/*", handler) + + ## Arguments + + | name | | description | + | --------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `url` | | A glob pattern, regex pattern, or predicate receiving a [URL]() used to register a routing handler via `route/4`. | + | `handler` | (optional) | A handler function provided when registering a routing handler via `route/4`. | + + ## Returns - @spec unroute(t(), binary(), function() | nil) :: :ok + - `BrowserContext.t()` + - `{:error, Error.t()}` + """ + @pipe {:unroute, [:context, :url]} + @pipe {:unroute, [:context, :url, :callback]} + @spec unroute(t(), binary(), function() | nil) :: t() | {:error, Error.t()} def unroute(%BrowserContext{session: session} = context, pattern, callback \\ nil) do with_latest(context, fn context -> remaining = @@ -601,13 +1207,63 @@ defmodule Playwright.BrowserContext do handler.matcher.match != pattern || (callback && handler.callback != callback) end) + patterns = Helpers.RouteHandler.prepare(remaining) + Channel.patch(session, {:guid, context.guid}, %{routes: remaining}) - :ok + Channel.post({context, :set_network_interception_patterns}, %{patterns: patterns}) end) end - # @spec unroute_all(t(), map()) :: :ok - # def unroute_all(context, options \\ %{}) + @doc """ + Removes all routes created via `Playwright.BrowserContext.route/4` and + `Playwright.BrowserContext.route_from_har/3`. + + ## Usage + + BrowserContext.unroute_all(context) + BrowserContext.unroute_all(context, %{behavior: "default"}) + BrowserContext.unroute_all(context, %{behavior: "ignoreErrors"}) + BrowserContext.unroute_all(context, %{behavior: "wait"}) + + ## Arguments + + | name | | description | + | --------- | ---------- | --------------------------------- | + | `context` | | The "subject" `APIRequestContext` | + | `options` | (optional) | Options (see below). | + + ### Options + + | name | | description | + | ----------- | ---------- | --------------------------------- | + | `:behavior` | (optional) | Specifies whether to wait for already running handlers, and what to do if they throw errors. | + + #### Detais for `:behavior` + + One of: + + - `"default"` - Do not wait for current handler calls, if any, to finish. + If a handler being removed throws, it may result in an unhandled error. + - `"ignoreErrors"` - Do not wait for current handler calls, if any, to finish. + Any errors thrown by handlers being removed are silently caught. + - `"wait"` - Wait for any current handler calls to finish. + + ## Returns + + - `BrowserContext.t()` + - `{:error, Error.t()}` + """ + @pipe {:unroute_all, [:context]} + @pipe {:unroute_all, [:context, :options]} + @spec unroute_all(t(), options()) :: t() | {:error, Error.t()} + def unroute_all(%BrowserContext{session: session} = context, options \\ %{}) do + with_latest(context, fn context -> + patterns = Helpers.RouteHandler.prepare([]) + + Channel.patch(session, {:guid, context.guid}, %{routes: []}) + Channel.post({context, :set_network_interception_patterns}, %{patterns: patterns}, options) + end) + end # @spec wait_for_event(t(), binary(), map()) :: map() # def wait_for_event(context, event, options \\ %{}) @@ -619,18 +1275,6 @@ defmodule Playwright.BrowserContext do Playwright.BindingCall.call(binding, Map.get(context.bindings, binding.name)) end - # NOTE: - # Still need to remove the handler when it does the job. Like the following: - # - # if handler_entry.matches(request.url): - # if handler_entry.handle(route, request): - # self._routes.remove(handler_entry) - # if not len(self._routes) == 0: - # asyncio.create_task(self._disable_interception()) - # break - # - # ...hoping for a test to drive that out. - # NOTE(20240525): # Do not love this; See Page.on_route/2 (which is an exact copy of this) for why. defp on_route(context, %{params: %{route: %{request: request} = route} = _params} = _event) do @@ -647,4 +1291,10 @@ defmodule Playwright.BrowserContext do end end) end + + defp serialize_headers(headers) when is_map(headers) do + Enum.into(headers, [], fn {name, value} -> + %{name: name, value: value} + end) + end end diff --git a/lib/playwright/browser_context/tracing.ex b/lib/playwright/browser_context/tracing.ex deleted file mode 100644 index 8bcee209..00000000 --- a/lib/playwright/browser_context/tracing.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Playwright.BrowserContext.Tracing do - @moduledoc false - - # @spec start(Tracing.t(), options()) :: :ok - # def start(tracing, options \\ %{}) - - # @spec start_chunk(Tracing.t(), options()) :: :ok - # def start_chunk(tracing, options \\ %{}) - - # @spec stop(Tracing.t(), options()) :: :ok - # def stop(tracing, options \\ %{}) - - # @spec stop_chunck(Tracing.t(), options()) :: :ok - # def stop_chunck(tracing, options \\ %{}) -end diff --git a/lib/playwright/browser_type.ex b/lib/playwright/browser_type.ex index 899658fa..32aea375 100644 --- a/lib/playwright/browser_type.ex +++ b/lib/playwright/browser_type.ex @@ -94,7 +94,7 @@ defmodule Playwright.BrowserType do # Use `:ignore_default_args` option to filter out `--mute-audio` from # default arguments: - {:ok, browser} = + {:ok, session, browser} = Playwright.launch(:chromium, %{ignore_default_args = ["--mute-audio"]}) ## Returns @@ -163,7 +163,7 @@ defmodule Playwright.BrowserType do # ---------------------------------------------------------------------------- defp browser(%BrowserType{} = browser_type) do - Channel.post(browser_type.session, {:guid, browser_type.guid}, :launch, Config.launch_options()) + Channel.post({browser_type, :launch}, Config.launch_options()) end defp chromium(session) do diff --git a/lib/playwright/cdp_session.ex b/lib/playwright/cdp_session.ex index 2ef20ae1..4ee23a31 100644 --- a/lib/playwright/cdp_session.ex +++ b/lib/playwright/cdp_session.ex @@ -27,15 +27,15 @@ defmodule Playwright.CDPSession do # API # --------------------------------------------------------------------------- - @spec detach(t()) :: :ok | {:error, term()} - def detach(%CDPSession{session: session} = cdp_session) do - Channel.post(session, {:guid, cdp_session.guid}, :detach) + @spec detach(t()) :: t() | {:error, Playwright.API.Error.t()} + def detach(%CDPSession{} = cdp_session) do + Channel.post({cdp_session, :detach}, %{refresh: false}) end @doc """ Register a (non-blocking) callback/handler for various types of events. """ - @spec on(t(), event(), function()) :: CDPSession.t() + @spec on(t(), event(), function()) :: t() def on(%CDPSession{bindings: bindings, session: session} = cdp_session, event, callback) do scoped = Map.get(bindings, event, []) bindings = Map.put(bindings, event, [callback | scoped]) @@ -43,14 +43,14 @@ defmodule Playwright.CDPSession do end @spec send(t(), binary(), options()) :: map() - def send(%CDPSession{session: session} = cdp_session, method, params \\ %{}) do - Channel.post(session, {:guid, cdp_session.guid}, :send, %{method: method, params: params}) + def send(%CDPSession{} = cdp_session, method, params \\ %{}) do + Channel.post({cdp_session, :send}, %{method: method, params: params}) end # private # --------------------------------------------------------------------------- - def handle_event(session, %{params: %{method: method, params: params}}) do + defp handle_event(session, %{params: %{method: method, params: params}}) do event = %{ params: params, target: session diff --git a/lib/playwright/element_handle.ex b/lib/playwright/element_handle.ex index dd01580d..eaeb07cd 100644 --- a/lib/playwright/element_handle.ex +++ b/lib/playwright/element_handle.ex @@ -54,6 +54,7 @@ defmodule Playwright.ElementHandle do use Playwright.SDK.ChannelOwner alias Playwright.{ElementHandle, Frame, JSHandle} + alias Playwright.API.Error alias Playwright.SDK.{Channel, ChannelOwner} @property :preview @@ -89,8 +90,8 @@ defmodule Playwright.ElementHandle do # --------------------------------------------------------------------------- @spec bounding_box(ElementHandle.t()) :: map() | nil - def bounding_box(%ElementHandle{session: session} = handle) do - Channel.post(session, {:guid, handle.guid}, :bounding_box) + def bounding_box(%ElementHandle{} = handle) do + Channel.post({handle, :bounding_box}) end @doc """ @@ -98,8 +99,8 @@ defmodule Playwright.ElementHandle do or `nil` otherwise. """ @spec content_frame(t()) :: Frame.t() | nil - def content_frame(%ElementHandle{session: session} = handle) do - Channel.post(session, {:guid, handle.guid}, :content_frame) + def content_frame(%ElementHandle{} = handle) do + Channel.post({handle, :content_frame}) end # @spec owner_frame(t()) :: Frame.t() | nil @@ -139,15 +140,9 @@ defmodule Playwright.ElementHandle do this function raises a `TimeoutError`. Passing zero (`0`) for timeout disables this. """ - @spec click(t(), options()) :: :ok - def click(%ElementHandle{session: session} = handle, options \\ %{}) do - case Channel.post(session, {:guid, handle.guid}, :click, options) do - {:ok, _} -> - :ok - - {:error, error} -> - {:error, error} - end + @spec click(t(), options()) :: t() + def click(%ElementHandle{} = handle, options \\ %{}) do + Channel.post({handle, :click}, options) end # --- @@ -175,8 +170,8 @@ defmodule Playwright.ElementHandle do Returns the value of an element's attribute. """ @spec get_attribute(t(), binary()) :: binary() | nil - def get_attribute(%ElementHandle{session: session} = handle, name) do - Channel.post(session, {:guid, handle.guid}, :get_attribute, %{name: name}) + def get_attribute(%ElementHandle{} = handle, name) do + Channel.post({handle, :get_attribute}, %{name: name}) end # --- @@ -218,21 +213,9 @@ defmodule Playwright.ElementHandle do # def is_hidden(handle) # ⚠️ DISCOURAGED - @spec is_visible(t()) :: boolean() - def is_visible(%ElementHandle{session: session} = handle) do - case Channel.post(session, {:guid, handle.guid}, :is_visible) do - false -> - false - - true -> - true - - {:ok, value} -> - value - - {:error, error} -> - {:error, error} - end + @spec is_visible(t()) :: boolean() | {:error, Error.t()} + def is_visible(%ElementHandle{} = handle) do + Channel.post({handle, :is_visible}) end # --- @@ -254,8 +237,8 @@ defmodule Playwright.ElementHandle do @spec query_selector(t(), binary()) :: ElementHandle.t() | nil def query_selector(handle, selector) - def query_selector(%ElementHandle{session: session} = handle, selector) do - Channel.post(session, {:guid, handle.guid}, :query_selector, %{selector: selector}) + def query_selector(%ElementHandle{} = handle, selector) do + Channel.post({handle, :query_selector}, %{selector: selector}) end defdelegate q(handle, selector), to: __MODULE__, as: :query_selector @@ -270,16 +253,16 @@ defmodule Playwright.ElementHandle do # ⚠️ DISCOURAGED @spec screenshot(ElementHandle.t(), options()) :: binary() - def screenshot(%ElementHandle{session: session} = handle, options \\ %{}) do + def screenshot(%ElementHandle{} = handle, options \\ %{}) do case Map.pop(options, :path) do {nil, params} -> - encoded = Channel.post(session, {:guid, handle.guid}, :screenshot, params) + encoded = Channel.post({handle, :screenshot}, params) Base.decode64!(encoded) {path, params} -> [_, filetype] = String.split(path, ".") - encoded = Channel.post(session, {:guid, handle.guid}, :screenshot, Map.put(params, :type, filetype)) + encoded = Channel.post({handle, :screenshot}, Map.put(params, :type, filetype)) decoded = Base.decode64!(encoded) File.write!(path, decoded) decoded @@ -288,8 +271,8 @@ defmodule Playwright.ElementHandle do # ⚠️ DISCOURAGED @spec scroll_into_view(ElementHandle.t(), options()) :: :ok - def scroll_into_view(%ElementHandle{session: session} = handle, options \\ %{}) do - Channel.post(session, {:guid, handle.guid}, :scroll_into_view_if_needed, options) + def scroll_into_view(%ElementHandle{} = handle, options \\ %{}) do + Channel.post({handle, :scroll_into_view_if_needed}, options) end # ⚠️ DISCOURAGED @@ -298,8 +281,8 @@ defmodule Playwright.ElementHandle do # ⚠️ DISCOURAGED @spec select_text(ElementHandle.t(), options()) :: :ok - def select_text(%ElementHandle{session: session} = handle, options \\ %{}) do - Channel.post(session, {:guid, handle.guid}, :select_text, options) + def select_text(%ElementHandle{} = handle, options \\ %{}) do + Channel.post({handle, :select_text}, options) end # ⚠️ DISCOURAGED @@ -323,8 +306,8 @@ defmodule Playwright.ElementHandle do @spec text_content(t()) :: binary() | nil def text_content(handle) - def text_content(%ElementHandle{session: session} = handle) do - Channel.post(session, {:guid, handle.guid}, :text_content) + def text_content(%ElementHandle{} = handle) do + Channel.post({handle, :text_content}) end # --- diff --git a/lib/playwright/frame.ex b/lib/playwright/frame.ex index e635054c..aa5a984e 100644 --- a/lib/playwright/frame.ex +++ b/lib/playwright/frame.ex @@ -16,6 +16,7 @@ defmodule Playwright.Frame do """ use Playwright.SDK.ChannelOwner alias Playwright.{ElementHandle, Frame, Locator, Response} + alias Playwright.API.Error alias Playwright.SDK.{ChannelOwner, Helpers} @property :load_states @@ -31,8 +32,8 @@ defmodule Playwright.Frame do # --------------------------------------------------------------------------- @impl ChannelOwner - def init(%Frame{session: session} = frame, _initializer) do - Channel.bind(session, {:guid, frame.guid}, :loadstate, fn %{params: params} = event -> + def init(%Frame{} = frame, _initializer) do + Channel.bind(frame.session, {:guid, frame.guid}, :loadstate, fn %{params: params} = event -> target = event.target case params do @@ -44,7 +45,7 @@ defmodule Playwright.Frame do end end) - Channel.bind(session, {:guid, frame.guid}, :navigated, fn event -> + Channel.bind(frame.session, {:guid, frame.guid}, :navigated, fn event -> {:patch, %{event.target | url: event.params.url}} end) @@ -56,30 +57,22 @@ defmodule Playwright.Frame do # --- - # @spec add_script_tag(Frame.t(), options()) :: ElementHandle.t() + # @spec add_script_tag(Frame.t(), options()) :: ElementHandle.t() | {:error, Error.t()} # def add_script_tag(frame, options \\ %{}) - # @spec add_style_tag(Frame.t(), options()) :: ElementHandle.t() + # @spec add_style_tag(Frame.t(), options()) :: ElementHandle.t() | {:error, Error.t()} # def add_style_tag(frame, options \\ %{}) # --- - @spec check(t(), binary(), options()) :: :ok - def check(%Frame{session: session} = frame, selector, options \\ %{}) do - params = Map.merge(%{selector: selector}, options) - - case Channel.post(session, {:guid, frame.guid}, :check, params) do - {:ok, _} -> - :ok - - {:error, error} -> - {:error, error} - end + @spec check(t(), binary(), options()) :: t() | {:error, Error.t()} + def check(%Frame{} = frame, selector, options \\ %{}) do + Channel.post({frame, :check}, %{selector: selector}, options) end # --- - # @spec child_frames(Frame.t()) :: [Frame.t()] + # @spec child_frames(Frame.t()) :: [Frame.t()] | {:error, Error.t()} # def child_frames(frame) # --- @@ -102,40 +95,26 @@ defmodule Playwright.Frame do `option: timeout`, `/click/3` raises a `TimeoutError`. Passing zero for `option: timeout` disables this. """ - @spec click(t(), binary(), options()) :: :ok - def click(owner, selector, options \\ %{}) - - def click(%Frame{session: session} = frame, selector, options) do - params = - Map.merge( - %{ - selector: selector, - timeout: 30_000, - wait_until: "load" - }, - options - ) + @spec click(t(), binary(), options()) :: t() | {:error, Error.t()} + def click(frame, selector, options \\ %{}) - case Channel.post(session, {:guid, frame.guid}, :click, params) do - {:ok, _} -> - :ok - - {:error, error} -> - {:error, error} - end + def click(%Frame{} = frame, selector, options) do + Channel.post( + {frame, :click}, + %{ + selector: selector, + timeout: 30_000, + wait_until: "load" + }, + options + ) end # --- - @spec content(Frame.t()) :: binary() | {:error, term()} - def content(%Frame{session: session} = frame) do - case Channel.post(session, {:guid, frame.guid}, :content) do - {:error, error} -> - {:error, error} - - content -> - content - end + @spec content(Frame.t()) :: binary() | {:error, Error.t()} + def content(%Frame{} = frame) do + Channel.post({frame, :content}) end # --- @@ -167,11 +146,12 @@ defmodule Playwright.Frame do ## Returns - - `:ok` + - `t()` + - `{:error, Error.t()}` ## Arguments - | key/name | type | | description | + | key/name | type | | description | | ---------------- | ------ | --------------------------------- | ----------- | | `selector` | param | `binary()` | A selector to search for an element. If there are multiple elements satisfying the selector, the first will be used. See "working with selectors (guide)" for more details. | | `:button` | option | `:left`, `:right` or `:middle` | `(default: :left)` | @@ -184,23 +164,9 @@ defmodule Playwright.Frame do | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed by using the `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2` functions. `(default: 30 seconds)` | | `:trial` | option | `boolean()` | When set, this call only performs the actionability checks and skips the action. Useful to wait until the element is ready for the action without performing it. `(default: false)` | """ - @spec dblclick(Frame.t(), binary(), options()) :: :ok - def dblclick(%Frame{session: session} = frame, selector, options \\ %{}) do - params = - Map.merge( - %{ - selector: selector - }, - options - ) - - case Channel.post(session, {:guid, frame.guid}, :dblclick, params) do - {:ok, _} -> - :ok - - {:error, error} -> - {:error, error} - end + @spec dblclick(Frame.t(), binary(), options()) :: t() | {:error, Error.t()} + def dblclick(%Frame{} = frame, selector, options \\ %{}) do + Channel.post({frame, :dblclick}, %{selector: selector}, options) end @doc """ @@ -240,11 +206,12 @@ defmodule Playwright.Frame do ## Returns - - `:ok` + - `t()` + - `{:error, Error.t()}` ## Arguments - | key/name | type | | description | + | key/name | type | | description | | ---------------- | ------ | ----------------------- | ----------- | | `selector` | param | `binary()` | A selector to search for an element. If there are multiple elements satisfying the selector, the first will be used. See "working with selectors (guide)" for more details. | | `type` | param | `atom()` or `binary()` | DOM event type: `:click`, `:dragstart`, etc. | @@ -252,7 +219,7 @@ defmodule Playwright.Frame do | `:strict` | option | `boolean()` | When true, the call requires selector to resolve to a single element. If given selector resolves to more then one element, the call throws an exception. | | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed by using the `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2` functions. `(default: 30 seconds)` | """ - @spec dispatch_event(Frame.t(), binary(), binary(), evaluation_argument(), options()) :: :ok + @spec dispatch_event(Frame.t(), binary(), binary(), evaluation_argument(), options()) :: t() | {:error, Error.t()} def dispatch_event(frame, selector, type, event_init \\ nil, options \\ %{}) def dispatch_event(%Frame{} = frame, selector, type, options, _) @@ -260,29 +227,29 @@ defmodule Playwright.Frame do dispatch_event(frame, selector, type, nil, options) end - def dispatch_event(%Frame{session: session} = frame, selector, type, event_init, options) do - params = - Map.merge(options, %{ + def dispatch_event(%Frame{} = frame, selector, type, event_init, options) do + Channel.post( + {frame, :dispatch_event}, + %{ selector: selector, type: type, - event_init: Helpers.Serialization.serialize(event_init) - }) - - Channel.post(session, {:guid, frame.guid}, :dispatch_event, params) + event_init: serialize(event_init) + }, + options + ) end # --- - @spec drag_and_drop(Frame.t(), binary(), binary(), options()) :: Frame.t() + @spec drag_and_drop(Frame.t(), binary(), binary(), options()) :: t() | {:error, Error.t()} def drag_and_drop(frame, source, target, options \\ %{}) do - params = Map.merge(%{source: source, target: target}, options) - post!(frame, :drag_and_drop, params) + Channel.post({frame, :drag_and_drop}, %{source: source, target: target}, options) end - # @spec eval_on_selector(Frame.t(), binary(), expression(), any(), options()) :: :ok + # @spec eval_on_selector(Frame.t(), binary(), expression(), any(), options()) :: ... # def eval_on_selector(frame, selector, expression, arg \\ nil, options \\ %{}) - # @spec eval_on_selector_all(Frame.t(), binary(), expression(), any(), options()) :: :ok + # @spec eval_on_selector_all(Frame.t(), binary(), expression(), any(), options()) :: ... # def eval_on_selector_all(frame, selector, expression, arg \\ nil, options \\ %{}) # --- @@ -292,12 +259,12 @@ defmodule Playwright.Frame do !!! """ - @spec eval_on_selector(Frame.t(), binary(), binary(), term(), map()) :: term() + @spec eval_on_selector(Frame.t(), binary(), binary(), term(), map()) :: term() | {:error, Error.t()} def eval_on_selector(frame, selector, expression, arg \\ nil, options \\ %{}) - def eval_on_selector(%Frame{session: session} = frame, selector, expression, arg, _options) do + def eval_on_selector(%Frame{} = frame, selector, expression, arg, _options) do parse_result(fn -> - Channel.post(session, {:guid, frame.guid}, :eval_on_selector, %{ + Channel.post({frame, :eval_on_selector}, %{ selector: selector, expression: expression, arg: serialize(arg) @@ -305,13 +272,16 @@ defmodule Playwright.Frame do end) end - def eval_on_selector_all(%Frame{session: session} = frame, selector, expression, arg \\ nil) do + def eval_on_selector_all(%Frame{} = frame, selector, expression, arg \\ nil) do parse_result(fn -> - Channel.post(session, {:guid, frame.guid}, :eval_on_selector_all, %{ - selector: selector, - expression: expression, - arg: Helpers.Serialization.serialize(arg) - }) + Channel.post( + {frame, :eval_on_selector_all}, + %{ + selector: selector, + expression: expression, + arg: serialize(arg) + } + ) end) end @@ -319,16 +289,19 @@ defmodule Playwright.Frame do Returns the return value of `expression`. !!! """ - @spec evaluate(t(), expression(), any()) :: :ok + @spec evaluate(t(), expression(), any()) :: term() | {:error, Error.t()} def evaluate(owner, expression, arg \\ nil) - def evaluate(%Frame{session: session} = frame, expression, arg) do + def evaluate(%Frame{} = frame, expression, arg) do parse_result(fn -> - Channel.post(session, {:guid, frame.guid}, :evaluate_expression, %{ - expression: expression, - is_function: Helpers.Expression.function?(expression), - arg: serialize(arg) - }) + Channel.post( + {frame, :evaluate_expression}, + %{ + expression: expression, + is_function: Helpers.Expression.function?(expression), + arg: serialize(arg) + } + ) end) end @@ -336,18 +309,22 @@ defmodule Playwright.Frame do Returns the return value of `expression` as a `Playwright.JSHandle`. !!! """ - @spec evaluate_handle(t(), expression(), any()) :: serializable() - def evaluate_handle(%Frame{session: session} = frame, expression, arg \\ nil) do - Channel.post(session, {:guid, frame.guid}, :evaluate_expression_handle, %{ - expression: expression, - is_function: Helpers.Expression.function?(expression), - arg: Helpers.Serialization.serialize(arg) - }) + @spec evaluate_handle(t(), expression(), any()) :: serializable() | {:error, Error.t()} + def evaluate_handle(%Frame{} = frame, expression, arg \\ nil) do + Channel.post( + {frame, :evaluate_expression_handle}, + %{ + expression: expression, + is_function: Helpers.Expression.function?(expression), + arg: serialize(arg) + }, + %{refresh: false} + ) end # --- - # @spec expect_navigation(Frame.t(), function(), options()) :: Playwright.Response.t() | nil + # @spec expect_navigation(Frame.t(), function(), options()) :: ... # def expect_navigation(frame, trigger, options \\ %{}) # --- @@ -371,11 +348,12 @@ defmodule Playwright.Frame do ## Returns - - `:ok` + - `t()` + - `{:error, Error.t()}` ## Arguments - | key/name | type | | description | + | key/name | type | | description | | ---------------- | ------ | --------------------------------- | ----------- | | `selector` | param | `binary()` | A selector to search for an element. If there are multiple elements satisfying the selector, the first will be used. See "working with selectors (guide)" for more details. | | `value` | param | `binary()` | Value to fill for the ``, `