diff --git a/.credo.exs b/.credo.exs index dfa26a9c..1f43a5a8 100644 --- a/.credo.exs +++ b/.credo.exs @@ -103,7 +103,8 @@ {Credo.Check.Readability.ModuleNames, []}, {Credo.Check.Readability.ParenthesesInCondition, []}, {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, - {Credo.Check.Readability.PredicateFunctionNames, []}, + # Disabled: is_* functions match Playwright's JavaScript API for consistency + {Credo.Check.Readability.PredicateFunctionNames, false}, {Credo.Check.Readability.PreferImplicitTry, []}, {Credo.Check.Readability.RedundantBlankLines, []}, {Credo.Check.Readability.Semicolons, []}, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d2ddf78..45139a34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: '1.16.2' - otp-version: '26.2.5' + elixir-version: '1.18.4' + otp-version: '27.3.4.6' - name: Install Elixir dependencies run: mix deps.get - name: Install Playwright dependencies (e.g., browsers) diff --git a/.gitignore b/.gitignore index 5c4953a9..c97992c0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ playwright-*.tar .doctor.out .vscode *.iml -/.idea/ \ No newline at end of file +/.idea/ +.claude/ diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 00000000..797a684e --- /dev/null +++ b/.mise.toml @@ -0,0 +1,4 @@ +[tools] +elixir = "1.18.4-otp-27" +erlang = "27.3.4.6" +nodejs = "22.21.1" diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 31fe85c1..00000000 --- a/.tool-versions +++ /dev/null @@ -1,3 +0,0 @@ -elixir 1.16.2-otp-26 -erlang 26.2.5 -nodejs 22.2.0 diff --git a/lib/playwright.ex b/lib/playwright.ex index 6b780f4d..901bb9d4 100644 --- a/lib/playwright.ex +++ b/lib/playwright.ex @@ -48,9 +48,9 @@ defmodule Playwright do | key/name | typ | | description | | ----------| ----- | ----------- | ----------- | | `client` | param | `client()` | The type of client (browser) to launch. | - | `options` | param | `options()` | `Playwright.SDK.Config.connect_options()` | + | `options` | param | `options()` | Connection options (see Config module) | """ - @spec launch(client(), Config.connect_options() | map()) :: {:ok, Playwright.Browser.t()} + @spec connect(client(), map()) :: {:ok, Playwright.Browser.t()} def connect(client, options \\ %{}) do options = Map.merge(Config.connect_options(), options) {:ok, session} = new_session(Playwright.SDK.Transport.WebSocket, options) @@ -70,9 +70,9 @@ defmodule Playwright do | key/name | typ | | description | | ----------| ----- | ----------- | ----------- | | `client` | param | `client()` | The type of client (browser) to launch. | - | `options` | param | `options()` | `Playwright.SDK.Config.launch_options()` | + | `options` | param | `options()` | Launch options (see Config module) | """ - @spec launch(client(), Config.launch_options() | map()) :: {:ok, Playwright.Browser.t()} + @spec launch(client(), map()) :: {:ok, Playwright.Browser.t()} def launch(client, options \\ %{}) do options = Map.merge(Config.launch_options(), options) {:ok, session} = new_session(Playwright.SDK.Transport.Driver, options) diff --git a/lib/playwright/api_request_context.ex b/lib/playwright/api_request_context.ex index 66692506..59ade49d 100644 --- a/lib/playwright/api_request_context.ex +++ b/lib/playwright/api_request_context.ex @@ -41,7 +41,7 @@ defmodule Playwright.APIRequestContext do # @spec patch(t(), binary(), options()) :: APIResponse.t() # def patch(context, url, options \\ %{}) - @spec post(t(), binary(), fetch_options()) :: Playwright.APIResponse.t() + @spec post(t(), binary(), fetch_options()) :: struct() def post(%APIRequestContext{session: session} = context, url, options \\ %{}) do Channel.post( session, @@ -64,7 +64,7 @@ defmodule Playwright.APIRequestContext do # def storage_state(context, options \\ %{}) # TODO: move to `APIResponse.body`, probably. - @spec body(t(), Playwright.APIResponse.t()) :: any() + @spec body(t(), struct()) :: any() def body(%APIRequestContext{session: session} = context, response) do Channel.post(session, {:guid, context.guid}, :fetch_response_body, %{ fetchUid: response.fetchUid diff --git a/lib/playwright/artifact.ex b/lib/playwright/artifact.ex new file mode 100644 index 00000000..8b1dbce0 --- /dev/null +++ b/lib/playwright/artifact.ex @@ -0,0 +1,64 @@ +defmodule Playwright.Artifact do + @moduledoc false + use Playwright.SDK.ChannelOwner + + @doc """ + Returns the path to the downloaded file after it has finished downloading. + """ + @spec path_after_finished(t()) :: binary() | {:error, term()} + def path_after_finished(%__MODULE__{session: session} = artifact) do + case Channel.post(session, {:guid, artifact.guid}, :path_after_finished) do + %{value: path} -> path + path when is_binary(path) -> path + {:error, _} = error -> error + end + end + + @doc """ + Saves the artifact to the specified path. + """ + @spec save_as(t(), binary()) :: :ok | {:error, term()} + def save_as(%__MODULE__{session: session} = artifact, path) do + case Channel.post(session, {:guid, artifact.guid}, :save_as, %{path: path}) do + {:ok, _} -> :ok + :ok -> :ok + {:error, _} = error -> error + end + end + + @doc """ + Returns the error message if the artifact failed to download, nil otherwise. + """ + @spec failure(t()) :: binary() | nil | {:error, term()} + def failure(%__MODULE__{session: session} = artifact) do + case Channel.post(session, {:guid, artifact.guid}, :failure) do + %{error: error} -> error + %{} -> nil + {:error, _} = error -> error + end + end + + @doc """ + Cancels the download. + """ + @spec cancel(t()) :: :ok | {:error, term()} + def cancel(%__MODULE__{session: session} = artifact) do + case Channel.post(session, {:guid, artifact.guid}, :cancel) do + {:ok, _} -> :ok + :ok -> :ok + {:error, _} = error -> error + end + end + + @doc """ + Deletes the downloaded file. + """ + @spec delete(t()) :: :ok | {:error, term()} + def delete(%__MODULE__{session: session} = artifact) do + case Channel.post(session, {:guid, artifact.guid}, :delete) do + {:ok, _} -> :ok + :ok -> :ok + {:error, _} = error -> error + end + end +end diff --git a/lib/playwright/browser.ex b/lib/playwright/browser.ex index e741ba7a..38348029 100644 --- a/lib/playwright/browser.ex +++ b/lib/playwright/browser.ex @@ -21,7 +21,7 @@ defmodule Playwright.Browser do - `:version` """ use Playwright.SDK.ChannelOwner - alias Playwright.{Browser, BrowserContext, Page} + alias Playwright.{Browser, BrowserContext, BrowserType, CDPSession, Page} alias Playwright.SDK.{Channel, ChannelOwner, Extra} @property :name @@ -44,8 +44,32 @@ defmodule Playwright.Browser do # API # --------------------------------------------------------------------------- - # @spec browser_type(t()) :: BrowserType.t() - # def browser_type(browser) + @doc """ + Returns the BrowserType that was used to launch this browser. + + ## Returns + + - `BrowserType.t()` + """ + @spec browser_type(t()) :: BrowserType.t() + def browser_type(%Browser{parent: parent}), do: parent + + @doc """ + Returns whether the browser is still connected. + + Returns `false` after the browser has been closed. + + ## Returns + + - `boolean()` + """ + @spec is_connected(t()) :: boolean() + def is_connected(%Browser{session: session, guid: guid}) do + case Channel.find(session, {:guid, guid}, %{timeout: 100}) do + %Browser{} -> true + {:error, _} -> false + end + end @doc """ Closes the browser. @@ -94,9 +118,6 @@ defmodule Playwright.Browser do Channel.list(browser.session, {:guid, browser.guid}, "BrowserContext") end - # @spec is_connected(BrowserContext.t()) :: boolean() - # def is_connected(browser) - # @spec new_browser_cdp_session(BrowserContext.t()) :: Playwright.CDPSession.t() # def new_browser_cdp_session(browser) @@ -146,33 +167,118 @@ defmodule Playwright.Browser do This is a convenience API function that should only be used for single-page scenarios and short snippets. Production code and testing frameworks should explicitly create via `Playwright.Browser.new_context/2` followed by - `Playwright.BrowserContext.new_page/2`, given the new context, to manage + `Playwright.BrowserContext.new_page/1`, given the new context, to manage resource lifecycles. """ - @spec new_page(t(), options()) :: Page.t() + @spec new_page(t(), options()) :: {:ok, Page.t()} | {:error, term()} def new_page(browser, options \\ %{}) def new_page(%Browser{session: session} = browser, options) do - context = new_context(browser, options) - page = BrowserContext.new_page(context) + with context when is_struct(context) <- new_context(browser, options), + page when is_struct(page) <- BrowserContext.new_page(context) do + # establish co-dependency + Channel.patch(session, {:guid, context.guid}, %{owner_page: page}) + Channel.patch(session, {:guid, page.guid}, %{owned_context: context}) + {:ok, page} + end + end + + # --- + + @doc """ + Start tracing for Chromium browser. + + Records a trace that can be viewed in Chrome DevTools or Playwright Trace Viewer. + + ## Arguments + + | key/name | type | description | + | -------------- | ------------- | ---------------------------------------- | + | `page` | `Page.t()` | Optional page to trace (default: all) | + | `:screenshots` | `boolean()` | Capture screenshots during trace | + | `:categories` | `[binary()]` | Trace categories to record | + + ## Returns - # TODO: handle the following, for `page`: - # ** (KeyError) key :guid not found in: {:error, %Playwright.Channel.Error{message: "Target closed"}} + - `:ok` - # establish co-dependency - Channel.patch(session, {:guid, context.guid}, %{owner_page: page}) - Channel.patch(session, {:guid, page.guid}, %{owned_context: context}) + ## Example + + Browser.start_tracing(browser) + # ... perform actions ... + trace = Browser.stop_tracing(browser) + File.write!("trace.json", trace) + + ## Note + + Only supported on Chromium browsers. + """ + @spec start_tracing(t(), Page.t() | nil, options()) :: :ok + def start_tracing(%Browser{session: session, guid: guid}, page \\ nil, options \\ %{}) do + params = %{} + params = if page, do: Map.put(params, :page, %{guid: page.guid}), else: params + params = if options[:screenshots], do: Map.put(params, :screenshots, options[:screenshots]), else: params + params = if options[:categories], do: Map.put(params, :categories, options[:categories]), else: params + + Channel.post(session, {:guid, guid}, :start_tracing, params) + :ok end - # --- + @doc """ + Stop tracing and return the trace data. + + Returns the trace data as binary which can be saved to a file. + + ## Returns + + - `binary()` - Trace data (JSON format, save as .json file) + + ## Example - # test_chromium_tracing.py - # @spec start_tracing(t(), Page.t(), options()) :: :ok - # def start_tracing(browser, page \\ nil, options \\ %{}) + Browser.start_tracing(browser, page, %{screenshots: true}) + Page.goto(page, "https://example.com") + trace = Browser.stop_tracing(browser) + File.write!("trace.json", trace) + """ + @spec stop_tracing(t()) :: binary() + def stop_tracing(%Browser{session: session, guid: guid}) do + artifact = + case Channel.post(session, {:guid, guid}, :stop_tracing, %{}) do + %{artifact: %{guid: artifact_guid}} -> + Channel.find(session, {:guid, artifact_guid}) + + %Playwright.Artifact{} = art -> + art + end + + # Save to temp file, read contents, then clean up + temp_path = Path.join(System.tmp_dir!(), "pw_trace_#{:erlang.unique_integer([:positive])}.json") + Playwright.Artifact.save_as(artifact, temp_path) + data = File.read!(temp_path) + File.rm(temp_path) + Playwright.Artifact.delete(artifact) + data + end - # test_chromium_tracing.py - # @spec stop_tracing(t()) :: binary() - # def stop_tracing(browser) + @doc """ + Creates a new CDP session attached to the browser. + + This is Chromium-specific. + + ## Returns + + - `CDPSession.t()` + + ## Example + + session = Browser.new_browser_cdp_session(browser) + # Use CDP commands... + CDPSession.detach(session) + """ + @spec new_browser_cdp_session(t()) :: CDPSession.t() + def new_browser_cdp_session(%Browser{session: session, guid: guid}) do + Channel.post(session, {:guid, guid}, "newBrowserCDPSession") + end # @spec version(BrowserContext.t()) :: binary # def version(browser) diff --git a/lib/playwright/browser_context.ex b/lib/playwright/browser_context.ex index 4324ef5b..c835b51d 100644 --- a/lib/playwright/browser_context.ex +++ b/lib/playwright/browser_context.ex @@ -29,7 +29,7 @@ defmodule Playwright.BrowserContext do The second argument is the event type. The third argument is a callback function that will be executed when the - event fires, and is passed an instance of `Playwright.SDK.Channel.Event`. + event fires, and is passed an event struct containing the event details. ### Details for `expect_event/5` @@ -160,13 +160,15 @@ defmodule Playwright.BrowserContext do """ use Playwright.SDK.ChannelOwner - alias Playwright.{BrowserContext, Frame, Page} + alias Playwright.{BrowserContext, Frame, Page, Worker} alias Playwright.SDK.{Channel, ChannelOwner, Helpers} @property :bindings @property :browser @property :owner_page @property :routes + @property :tracing + @property :websocket_routes @typedoc "Recognized cookie fields" @type cookie :: %{ @@ -215,7 +217,12 @@ defmodule Playwright.BrowserContext do # NOTE: will patch here end) - {:ok, %{context | bindings: %{}, browser: context.parent, routes: []}} + Channel.bind(session, {:guid, context.guid}, :web_socket_route, fn %{params: params, target: target} -> + on_web_socket_route(target, params) + :ok + end) + + {:ok, %{context | bindings: %{}, browser: context.parent, routes: [], websocket_routes: []}} end # API @@ -318,8 +325,19 @@ defmodule Playwright.BrowserContext do # --- - # @spec background_pages(t()) :: [Playwright.Page.t()] - # def background_pages(context) + @doc """ + Returns all background pages in the context. + + This only works with Chromium persistent contexts. + + ## Returns + + - `[Page.t()]` + """ + @spec background_pages(t()) :: [Page.t()] + def background_pages(%BrowserContext{}) do + [] + end # @spec browser(t()) :: Playwright.Browser.t() # def browser(context) @@ -386,18 +404,18 @@ defmodule Playwright.BrowserContext do predicate function. Returns when the predicate returns a truthy value. Throws an error if the - context closes before the event is fired. Returns a `Playwright.SDK.Channel.Event`. + context closes before the event is fired. Returns a channel event struct. ## Arguments - `event`: Event name; the same as those passed to `Playwright.BrowserContext.on/3` - - `predicate`: Receives the `Playwright.SDK.Channel.Event` and resolves to a + - `predicate`: Receives the channel event and resolves to a "truthy" value when the waiting should resolve. - `options`: - `predicate`: ... - `timeout`: The maximum time to wait in milliseconds. Defaults to 30000 (30 seconds). Pass 0 to disable timeout. The default value can be changed - via `Playwright.BrowserContext.set_default_timeout/2`. + via BrowserContext timeout settings. ## Example @@ -416,7 +434,7 @@ defmodule Playwright.BrowserContext do the `Playwright.BrowserContext`. If `predicate` is provided, it passes the `Playwright.Page` value into the - predicate function, wrapped in `Playwright.SDK.Channel.Event`, and waits for + predicate function, wrapped in a channel event struct, and waits for `predicate/1` to return a "truthy" value. Throws an error if the context closes before new `Playwright.Page` is created. @@ -428,10 +446,9 @@ defmodule Playwright.BrowserContext do - `predicate`: ... - `timeout`: The maximum time to wait in milliseconds. Defaults to 30000 (30 seconds). Pass 0 to disable timeout. The default value can be changed - via `Playwright.BrowserContext.set_default_timeout/2`. + via BrowserContext timeout settings. """ - # Temporarily disable spec: - # @spec expect_page(t(), map(), function()) :: Playwright.SDK.Channel.Event.t() + # Temporarily disable spec (channel event type is internal) def expect_page(context, options \\ %{}, trigger \\ nil) do expect_event(context, :page, options, trigger) end @@ -476,13 +493,13 @@ defmodule Playwright.BrowserContext do end) end - @spec grant_permissions(t(), [String.t()], options()) :: :ok | {:error, Channel.Error.t()} + @spec grant_permissions(t(), [String.t()], options()) :: :ok | {:error, term()} 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) end - @spec new_cdp_session(t(), Frame.t() | Page.t()) :: Playwright.CDPSession.t() + @spec new_cdp_session(t(), Frame.t() | Page.t()) :: struct() def new_cdp_session(context, owner) def new_cdp_session(%BrowserContext{session: session} = context, %Frame{} = frame) do @@ -533,7 +550,7 @@ defmodule Playwright.BrowserContext do Channel.list(context.session, {:guid, context.guid}, "Page") end - @spec route(t(), binary(), function(), map()) :: :ok + @spec route(t(), binary() | Regex.t(), function(), map()) :: t() | {:error, term()} def route(context, pattern, handler, options \\ %{}) def route(%BrowserContext{session: session} = context, pattern, handler, _options) do @@ -554,30 +571,202 @@ defmodule Playwright.BrowserContext do # @spec route_from_har(t(), binary(), map()) :: :ok # def route(context, har, options \\ %{}) - # ??? - # @spec service_workers(t()) :: [Playwright.Worker.t()] - # def service_workers(context) + @doc """ + Routes WebSocket connections matching the URL pattern to the handler. + + This applies to all pages within the context. + + ## Example + + BrowserContext.route_web_socket(context, "**/ws", fn ws -> + server = WebSocketRoute.connect_to_server(ws) + + WebSocketRoute.on_message(ws, fn msg -> + IO.puts("Page -> Server: \#{msg}") + WebSocketRoute.Server.send(server, msg) + end) + + WebSocketRoute.Server.on_message(server, fn msg -> + IO.puts("Server -> Page: \#{msg}") + WebSocketRoute.send(ws, msg) + end) + end) + """ + @spec route_web_socket(t(), binary() | Regex.t(), (Playwright.WebSocketRoute.t() -> any())) :: + t() | {:error, term()} + def route_web_socket(%BrowserContext{session: session} = context, pattern, handler) + when is_function(handler, 1) do + with_latest(context, fn context -> + matcher = Helpers.URLMatcher.new(pattern) + ws_handler = Helpers.WebSocketRouteHandler.new(matcher, handler) + + websocket_routes = [ws_handler | context.websocket_routes] + patterns = Helpers.WebSocketRouteHandler.prepare(websocket_routes) + + Channel.patch(session, {:guid, context.guid}, %{websocket_routes: websocket_routes}) + Channel.post(session, {:guid, context.guid}, :set_web_socket_interception_patterns, %{patterns: patterns}) + end) + end + + @doc """ + Returns all service workers in the context. + + ## Returns + + - `[Worker.t()]` + + ## Note + + Currently returns an empty list. Full tracking of service workers + via events is a future enhancement. + """ + @spec service_workers(t()) :: [Worker.t()] + def service_workers(%BrowserContext{}) do + [] + end + + @doc """ + Sets the default timeout for all context operations. + + This setting will change the default maximum time for all the methods + accepting a `timeout` option. + + ## Arguments + + | key/name | type | description | + | --------- | ---------- | -------------------------------- | + | `timeout` | `number()` | Maximum time in milliseconds. | + + ## Returns + + - `:ok` + + ## Example + + BrowserContext.set_default_timeout(context, 60_000) # 60 seconds + """ + @spec set_default_timeout(t(), number()) :: :ok + def set_default_timeout(%BrowserContext{session: session, guid: guid}, timeout) do + Channel.post(session, {:guid, guid}, :set_default_timeout_no_reply, %{timeout: timeout}) + :ok + end + + @doc """ + Sets the default timeout for navigation operations. + + This setting will change the default maximum navigation time for the + following methods and related shortcuts on page: `goto`, `go_back`, + `go_forward`, `reload`, `wait_for_navigation`. + + ## Arguments + + | key/name | type | description | + | --------- | ---------- | -------------------------------- | + | `timeout` | `number()` | Maximum time in milliseconds. | + + ## Returns + + - `:ok` + + ## Example + + BrowserContext.set_default_navigation_timeout(context, 90_000) # 90 seconds + """ + @spec set_default_navigation_timeout(t(), number()) :: :ok + def set_default_navigation_timeout(%BrowserContext{session: session, guid: guid}, timeout) do + Channel.post(session, {:guid, guid}, :set_default_navigation_timeout_no_reply, %{timeout: timeout}) + :ok + end + + @doc """ + Sets extra HTTP headers to be sent with every request. + + These headers will be sent with every request initiated by any page in + the context. Headers set with `Page.set_extra_http_headers/2` take + precedence over headers set with this method. + + ## Arguments + + | key/name | type | description | + | --------- | -------- | ------------------------------------------ | + | `headers` | `map()` | Map of header names to values. | + + ## Returns + + - `:ok` + + ## Example + + BrowserContext.set_extra_http_headers(context, %{ + "Authorization" => "Bearer token123", + "X-Custom-Header" => "value" + }) + """ + @spec set_extra_http_headers(t(), map()) :: :ok + def set_extra_http_headers(%BrowserContext{session: session, guid: guid}, headers) when is_map(headers) do + header_list = Enum.map(headers, fn {name, value} -> %{name: to_string(name), value: value} end) + Channel.post(session, {:guid, guid}, :set_extra_http_headers, %{headers: header_list}) + :ok + end + + @doc """ + Sets the geolocation for this browser context. + + All pages in this context will use the overridden geolocation. + + ## Parameters + + - `geolocation` - A map with `:latitude`, `:longitude`, and optional `:accuracy`. + - `:latitude` - Latitude between -90 and 90 + - `:longitude` - Longitude between -180 and 180 + - `:accuracy` - Non-negative accuracy in meters (default: 0) - # test_navigation.py - # @spec set_default_navigation_timeout(t(), number()) :: :ok - # def set_default_navigation_timeout(context, timeout) + ## Examples - # test_navigation.py - # @spec set_default_timeout(t(), number()) :: :ok - # def set_default_timeout(context, timeout) + # Set geolocation to San Francisco + BrowserContext.set_geolocation(context, %{latitude: 37.7749, longitude: -122.4194}) - # test_interception.py - # test_network.py - # @spec set_extra_http_headers(t(), headers()) :: :ok - # def set_extra_http_headers(context, headers) + # With accuracy + BrowserContext.set_geolocation(context, %{latitude: 37.7749, longitude: -122.4194, accuracy: 100}) - # test_geolocation.py - # @spec set_geolocation(t(), geolocation()) :: :ok - # def set_geolocation(context, geolocation) + ## Note - # ??? - # @spec set_http_credentials(t(), http_credentials()) :: :ok - # def set_http_credentials(context, http_credentials) + Consider also granting the 'geolocation' permission: + + BrowserContext.grant_permissions(context, ["geolocation"]) + """ + @spec set_geolocation(t(), map()) :: :ok + def set_geolocation(%BrowserContext{session: session} = context, geolocation) do + Channel.post(session, {:guid, context.guid}, :set_geolocation, %{geolocation: geolocation}) + end + + @doc """ + Sets HTTP authentication credentials for requests. + + Pass `nil` to disable authentication. + + ## Arguments + + | key/name | type | description | + | ------------- | -------------------------------------------------- | ------------------------------- | + | `credentials` | `%{username: binary(), password: binary()} \| nil` | Credentials or nil to disable. | + + ## Returns + + - `:ok` + + ## Example + + BrowserContext.set_http_credentials(context, %{username: "user", password: "pass"}) + # Later, to disable: + BrowserContext.set_http_credentials(context, nil) + """ + @spec set_http_credentials(t(), %{username: binary(), password: binary()} | nil) :: :ok + def set_http_credentials(%BrowserContext{session: session, guid: guid}, credentials) do + params = %{httpCredentials: credentials} + Channel.post(session, {:guid, guid}, :set_http_credentials, params) + :ok + end # --- @@ -588,12 +777,47 @@ defmodule Playwright.BrowserContext do # --- - # @spec storage_state(t(), String.t()) :: {:ok, storage_state()} - # def storage_state(context, path \\ nil) + @doc """ + Returns storage state for this browser context, contains current cookies and local storage snapshot. + + ## Options + + - `:path` - Optional path to save the storage state as JSON file. + + ## Examples + + # Get storage state as map + state = BrowserContext.storage_state(context) + + # Save to file + state = BrowserContext.storage_state(context, path: "/tmp/auth.json") + + ## Returns + + A map with `:cookies` and `:origins` keys. + """ + @spec storage_state(t(), keyword()) :: map() | {:error, term()} + def storage_state(%BrowserContext{session: session} = context, options \\ []) do + case Channel.post(session, {:guid, context.guid}, :storage_state) do + {:error, _} = error -> + error + + state when is_map(state) -> + case Keyword.get(options, :path) do + nil -> + state + + path -> + json = Jason.encode!(state, pretty: true) + File.write!(path, json) + state + end + end + end # --- - @spec unroute(t(), binary(), function() | nil) :: :ok + @spec unroute(t(), binary() | Regex.t(), function() | nil) :: t() | {:error, term()} def unroute(%BrowserContext{session: session} = context, pattern, callback \\ nil) do with_latest(context, fn context -> remaining = @@ -606,8 +830,41 @@ defmodule Playwright.BrowserContext do end) end - # @spec unroute_all(t(), map()) :: :ok - # def unroute_all(context, options \\ %{}) + @doc """ + Removes all routes registered with `route/4`. + + ## Options + + | key/name | type | description | + | ---------- | ---------- | ------------------------------------------------ | + | `:behavior`| `binary()` | How to handle in-flight requests. One of: | + | | | `"default"` - abort in-flight requests | + | | | `"wait"` - wait for in-flight handlers | + | | | `"ignoreErrors"` - ignore handler errors | + + ## Returns + + - `:ok` + + ## Example + + # Add a route + BrowserContext.route(context, "**/*", fn route -> Route.abort(route) end) + + # Later, remove all routes + BrowserContext.unroute_all(context) + + # Or wait for in-flight handlers to complete + BrowserContext.unroute_all(context, %{behavior: "wait"}) + """ + @spec unroute_all(t(), map()) :: :ok + def unroute_all(%BrowserContext{session: session, guid: guid}, options \\ %{}) do + params = if options[:behavior], do: %{behavior: options[:behavior]}, else: %{} + Channel.post(session, {:guid, guid}, :unroute_all, params) + Channel.patch(session, {:guid, guid}, %{routes: []}) + Channel.post(session, {:guid, guid}, :set_network_interception_patterns, %{patterns: []}) + :ok + end # @spec wait_for_event(t(), binary(), map()) :: map() # def wait_for_event(context, event, options \\ %{}) @@ -647,4 +904,25 @@ defmodule Playwright.BrowserContext do end end) end + + defp on_web_socket_route(context, %{webSocketRoute: ws_route}) do + # ws_route is already hydrated by Event.new + handle_web_socket_route(context, ws_route) + end + + @doc false + # Called by Page when it has no matching handler + def handle_web_socket_route(context, ws_route) do + handler = + Enum.find(context.websocket_routes, fn h -> + Helpers.WebSocketRouteHandler.matches(h, ws_route.url) + end) + + if handler do + Helpers.WebSocketRouteHandler.handle(handler, ws_route) + else + # No handler, connect through + Playwright.WebSocketRoute.connect_to_server(ws_route) + end + end end diff --git a/lib/playwright/clock.ex b/lib/playwright/clock.ex new file mode 100644 index 00000000..0bc8e2e5 --- /dev/null +++ b/lib/playwright/clock.ex @@ -0,0 +1,194 @@ +defmodule Playwright.Clock do + @moduledoc """ + Clock provides methods for controlling time in tests. + + Accessed via BrowserContext. Call `Clock.install/2` before using other methods. + + ## Example + + context = Browser.new_context(browser) + Clock.install(context, %{time: "2024-01-01T00:00:00Z"}) + Clock.fast_forward(context, "01:00:00") # 1 hour + + ## Time Formats + + Time can be specified as: + - Number: milliseconds since epoch (e.g., `1704067200000`) + - String: ISO 8601 format (e.g., `"2024-01-01T00:00:00Z"`) + - DateTime: Elixir DateTime struct + + ## Ticks Formats + + Ticks (duration) can be specified as: + - Number: milliseconds (e.g., `1000` = 1 second) + - String: `"mm:ss"` or `"hh:mm:ss"` format (e.g., `"01:30"` = 90 seconds) + """ + + alias Playwright.BrowserContext + alias Playwright.SDK.Channel + + @doc """ + Install fake timers. Must be called before using other clock methods. + + ## Arguments + + | key/name | type | description | + | -------- | ---- | ----------- | + | `context` | `BrowserContext.t()` | The browser context | + | `options` | `map()` | Optional settings | + + ## Options + + - `:time` - Initial time (number ms, ISO string, or DateTime) + + ## Returns + + - `:ok` + """ + @spec install(BrowserContext.t(), map()) :: :ok + def install(%BrowserContext{session: session, guid: guid}, options \\ %{}) do + params = parse_time_param(options[:time]) + Channel.post(session, {:guid, guid}, :clock_install, params) + :ok + end + + @doc """ + Advance time by jumping forward, firing due timers along the way. + + ## Arguments + + | key/name | type | description | + | -------- | ---- | ----------- | + | `context` | `BrowserContext.t()` | The browser context | + | `ticks` | `number() \\| String.t()` | Duration to advance (ms or "hh:mm:ss") | + + ## Returns + + - `:ok` + """ + @spec fast_forward(BrowserContext.t(), number() | String.t()) :: :ok + def fast_forward(%BrowserContext{session: session, guid: guid}, ticks) do + params = parse_ticks_param(ticks) + Channel.post(session, {:guid, guid}, :clock_fast_forward, params) + :ok + end + + @doc """ + Pause the clock at the specified time. + + ## Arguments + + | key/name | type | description | + | -------- | ---- | ----------- | + | `context` | `BrowserContext.t()` | The browser context | + | `time` | `number() \\| String.t() \\| DateTime.t()` | Time to pause at | + + ## Returns + + - `:ok` + """ + @spec pause_at(BrowserContext.t(), number() | String.t() | DateTime.t()) :: :ok + def pause_at(%BrowserContext{session: session, guid: guid}, time) do + params = parse_time_param(time) + Channel.post(session, {:guid, guid}, :clock_pause_at, params) + :ok + end + + @doc """ + Resume the clock after it was paused. + + ## Arguments + + | key/name | type | description | + | -------- | ---- | ----------- | + | `context` | `BrowserContext.t()` | The browser context | + + ## Returns + + - `:ok` + """ + @spec resume(BrowserContext.t()) :: :ok + def resume(%BrowserContext{session: session, guid: guid}) do + Channel.post(session, {:guid, guid}, :clock_resume, %{}) + :ok + end + + @doc """ + Run the clock for the specified time, firing all due timers. + + Unlike `fast_forward/2`, this executes timers synchronously. + + ## Arguments + + | key/name | type | description | + | -------- | ---- | ----------- | + | `context` | `BrowserContext.t()` | The browser context | + | `ticks` | `number() \\| String.t()` | Duration to run (ms or "hh:mm:ss") | + + ## Returns + + - `:ok` + """ + @spec run_for(BrowserContext.t(), number() | String.t()) :: :ok + def run_for(%BrowserContext{session: session, guid: guid}, ticks) do + params = parse_ticks_param(ticks) + Channel.post(session, {:guid, guid}, :clock_run_for, params) + :ok + end + + @doc """ + Set the clock to a fixed time. Time will not advance automatically. + + Useful for testing time-dependent behavior at a specific moment. + + ## Arguments + + | key/name | type | description | + | -------- | ---- | ----------- | + | `context` | `BrowserContext.t()` | The browser context | + | `time` | `number() \\| String.t() \\| DateTime.t()` | Time to fix at | + + ## Returns + + - `:ok` + """ + @spec set_fixed_time(BrowserContext.t(), number() | String.t() | DateTime.t()) :: :ok + def set_fixed_time(%BrowserContext{session: session, guid: guid}, time) do + params = parse_time_param(time) + Channel.post(session, {:guid, guid}, :clock_set_fixed_time, params) + :ok + end + + @doc """ + Set the system time but allow it to advance naturally. + + Unlike `set_fixed_time/2`, time will continue to flow after being set. + + ## Arguments + + | key/name | type | description | + | -------- | ---- | ----------- | + | `context` | `BrowserContext.t()` | The browser context | + | `time` | `number() \\| String.t() \\| DateTime.t()` | Time to set | + + ## Returns + + - `:ok` + """ + @spec set_system_time(BrowserContext.t(), number() | String.t() | DateTime.t()) :: :ok + def set_system_time(%BrowserContext{session: session, guid: guid}, time) do + params = parse_time_param(time) + Channel.post(session, {:guid, guid}, :clock_set_system_time, params) + :ok + end + + # Private helpers + + defp parse_time_param(nil), do: %{} + defp parse_time_param(time) when is_number(time), do: %{timeNumber: time} + defp parse_time_param(time) when is_binary(time), do: %{timeString: time} + defp parse_time_param(%DateTime{} = dt), do: %{timeNumber: DateTime.to_unix(dt, :millisecond)} + + defp parse_ticks_param(ticks) when is_number(ticks), do: %{ticksNumber: ticks} + defp parse_ticks_param(ticks) when is_binary(ticks), do: %{ticksString: ticks} +end diff --git a/lib/playwright/coverage.ex b/lib/playwright/coverage.ex new file mode 100644 index 00000000..aa4a7ba7 --- /dev/null +++ b/lib/playwright/coverage.ex @@ -0,0 +1,108 @@ +defmodule Playwright.Coverage do + @moduledoc """ + Coverage module for collecting JavaScript and CSS code coverage. + + This is Chromium-specific functionality. + + ## Example + + # Start JS coverage + Coverage.start_js_coverage(page) + + # Navigate and interact + Page.goto(page, "https://example.com") + + # Stop and get coverage data + entries = Coverage.stop_js_coverage(page) + """ + + alias Playwright.Page + alias Playwright.SDK.Channel + + @type js_coverage_options :: %{ + optional(:reset_on_navigation) => boolean(), + optional(:report_anonymous_scripts) => boolean() + } + + @type css_coverage_options :: %{ + optional(:reset_on_navigation) => boolean() + } + + @doc """ + Starts JavaScript coverage collection. + + ## Options + + - `:reset_on_navigation` - Whether to reset coverage on every navigation (default: true) + - `:report_anonymous_scripts` - Whether to report anonymous scripts (default: false) + + ## Returns + + - `:ok` + """ + @spec start_js_coverage(Page.t(), js_coverage_options()) :: :ok + def start_js_coverage(%Page{session: session, guid: guid}, options \\ %{}) do + params = camelize_options(options) + Channel.post(session, {:guid, guid}, "startJSCoverage", params) + :ok + end + + @doc """ + Stops JavaScript coverage collection and returns the coverage data. + + ## Returns + + A list of coverage entries, each containing: + - `:url` - Script URL + - `:scriptId` - Script ID + - `:source` - Script source (optional) + - `:functions` - List of function coverage data + """ + @spec stop_js_coverage(Page.t()) :: [map()] + def stop_js_coverage(%Page{session: session, guid: guid}) do + Channel.post(session, {:guid, guid}, "stopJSCoverage") + end + + @doc """ + Starts CSS coverage collection. + + ## Options + + - `:reset_on_navigation` - Whether to reset coverage on every navigation (default: true) + + ## Returns + + - `:ok` + """ + @spec start_css_coverage(Page.t(), css_coverage_options()) :: :ok + def start_css_coverage(%Page{session: session, guid: guid}, options \\ %{}) do + params = camelize_options(options) + Channel.post(session, {:guid, guid}, "startCSSCoverage", params) + :ok + end + + @doc """ + Stops CSS coverage collection and returns the coverage data. + + ## Returns + + A list of coverage entries, each containing: + - `:url` - Stylesheet URL + - `:text` - Stylesheet text (optional) + - `:ranges` - List of used ranges + """ + @spec stop_css_coverage(Page.t()) :: [map()] + def stop_css_coverage(%Page{session: session, guid: guid}) do + Channel.post(session, {:guid, guid}, "stopCSSCoverage") + end + + defp camelize_options(options) do + options + |> Enum.map(fn + {:reset_on_navigation, v} -> {:resetOnNavigation, v} + {:report_anonymous_scripts, v} -> {:reportAnonymousScripts, v} + other -> other + end) + |> Map.new() + end +end diff --git a/lib/playwright/dialog.ex b/lib/playwright/dialog.ex index d19b916a..017c55ef 100644 --- a/lib/playwright/dialog.ex +++ b/lib/playwright/dialog.ex @@ -1,22 +1,107 @@ defmodule Playwright.Dialog do - @moduledoc false + @moduledoc """ + `Playwright.Dialog` instances are dispatched by page and handled via + `Playwright.Page.on/3` for the `:dialog` event type. + + ## Dialog Types + + - `"alert"` - Alert dialog with OK button + - `"confirm"` - Confirm dialog with OK and Cancel buttons + - `"prompt"` - Prompt dialog with text input + - `"beforeunload"` - Before unload dialog + + ## Example + + Page.on(page, :dialog, fn dialog -> + IO.puts("Dialog message: \#{Dialog.message(dialog)}") + Dialog.accept(dialog) + end) + + # For prompts with input: + Page.on(page, :dialog, fn dialog -> + Dialog.accept(dialog, "my input") + end) + """ use Playwright.SDK.ChannelOwner + alias Playwright.SDK.{Channel, ChannelOwner} + + @property :default_value + @property :message + @property :dialog_type + + # callbacks + # --------------------------------------------------------------------------- + + @impl ChannelOwner + def init(dialog, initializer) do + {:ok, + %{ + dialog + | default_value: initializer.default_value, + message: initializer.message, + dialog_type: initializer.type + }} + end + + @doc """ + Get the dialog type. + + Returns one of: `"alert"`, `"confirm"`, `"prompt"`, `"beforeunload"`. + """ + @spec type(t()) :: binary() + def type(dialog) do + dialog_type(dialog) + end + + # API + # --------------------------------------------------------------------------- + + @doc """ + Accept the dialog. + + For prompt dialogs, optionally provide text input. + + ## Arguments + + - `prompt_text` - Text to enter in prompt dialog (optional) + + ## Examples - # @spec accept(Dialog.t(), binary()) :: :ok - # def accept(dialog, prompt \\ "") + Dialog.accept(dialog) + Dialog.accept(dialog, "my input") + """ + @spec accept(t(), binary() | nil) :: :ok + def accept(dialog, prompt_text \\ nil) - # @spec default_value(Dialog.t()) :: binary() - # def default_value(dialog) + def accept(%__MODULE__{session: session} = dialog, nil) do + Channel.post(session, {:guid, dialog.guid}, :accept, %{}) + :ok + end - # @spec dismiss(Dialog.t()) :: :ok - # def dismiss(dialog) + def accept(%__MODULE__{session: session} = dialog, prompt_text) when is_binary(prompt_text) do + Channel.post(session, {:guid, dialog.guid}, :accept, %{promptText: prompt_text}) + :ok + end - # @spec message(Dialog.t()) :: binary() - # def message(dialog) + @doc """ + Dismiss the dialog (click Cancel or close). + """ + @spec dismiss(t()) :: :ok + def dismiss(%__MODULE__{session: session} = dialog) do + Channel.post(session, {:guid, dialog.guid}, :dismiss, %{}) + :ok + end - # @spec page(Dialog.t()) :: nil | Page.t() - # def page(dialog) + @doc """ + Get the page that initiated the dialog. - # @spec type(Dialog.t()) :: binary() - # def type(dialog) + Returns `nil` if dialog was triggered by a different context. + """ + @spec page(t()) :: Playwright.Page.t() | nil + def page(%__MODULE__{session: session, parent: parent}) do + case parent do + %{guid: guid} -> Channel.find(session, {:guid, guid}) + _ -> nil + end + end end diff --git a/lib/playwright/element_handle.ex b/lib/playwright/element_handle.ex index dd01580d..105bae52 100644 --- a/lib/playwright/element_handle.ex +++ b/lib/playwright/element_handle.ex @@ -16,8 +16,8 @@ defmodule Playwright.ElementHandle do :ok = ElementHandle.click(handle) `ElementHandle` prevents DOM elements from garbage collection unless the - handle is disposed with `Playwright.JSHandle.dispose/1`. `ElementHandles` - are auto-disposed when their origin frame is navigated. + handle is disposed. `ElementHandles` are auto-disposed when their origin + frame is navigated. An `ElementHandle` instance can be used as an argument in `Playwright.Page.eval_on_selector/5` and `Playwright.Page.evaluate/3`. @@ -306,9 +306,68 @@ defmodule Playwright.ElementHandle do # @spec set_checked(ElementHandle.t(), boolean(), options()) :: :ok # def set_checked(handle, checked, options \\ %{}) - # ⚠️ DISCOURAGED - # @spec set_input_files(ElementHandle.t(), file_list(), options()) :: :ok - # def set_input_files(handle, files, options \\ %{}) + @doc """ + Sets the value of file input elements. + + ## Arguments + + - `files` - File path(s) or file payload(s) + - `options` - Optional settings + + ## File payload format + + %{name: "file.txt", mimeType: "text/plain", buffer: "base64content"} + """ + @spec set_input_files(t(), binary() | [binary()] | map() | [map()], map()) :: :ok | {:error, term()} + def set_input_files(%ElementHandle{session: session} = handle, files, options \\ %{}) do + file_payloads = prepare_files(files) + timeout = Map.get(options, :timeout, 30_000) + params = %{payloads: file_payloads, timeout: timeout} + + case Channel.post(session, {:guid, handle.guid}, :set_input_files, params) do + {:ok, _} -> :ok + :ok -> :ok + nil -> :ok + {:error, _} = error -> error + end + end + + defp prepare_files(files) when is_binary(files), do: prepare_files([files]) + + defp prepare_files(files) when is_list(files) do + Enum.map(files, fn + %{name: _, buffer: _} = payload -> + payload + + path when is_binary(path) -> + %{ + name: Path.basename(path), + buffer: Base.encode64(File.read!(path)), + mimeType: mime_type(path) + } + end) + end + + defp prepare_files(%{} = file), do: [file] + + @mime_types %{ + ".txt" => "text/plain", + ".html" => "text/html", + ".css" => "text/css", + ".js" => "application/javascript", + ".json" => "application/json", + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".pdf" => "application/pdf", + ".xml" => "application/xml", + ".zip" => "application/zip" + } + + defp mime_type(path) do + Map.get(@mime_types, Path.extname(path), "application/octet-stream") + end # ⚠️ DISCOURAGED # @spec tap(ElementHandle.t(), options()) :: :ok diff --git a/lib/playwright/fetch_request.ex b/lib/playwright/fetch_request.ex deleted file mode 100644 index 655a4f9b..00000000 --- a/lib/playwright/fetch_request.ex +++ /dev/null @@ -1,6 +0,0 @@ -defmodule Playwright.FetchRequest do - @moduledoc false - use Playwright.SDK.ChannelOwner - - # obsolete? -end diff --git a/lib/playwright/frame.ex b/lib/playwright/frame.ex index e635054c..e1ef0a97 100644 --- a/lib/playwright/frame.ex +++ b/lib/playwright/frame.ex @@ -1,8 +1,7 @@ defmodule Playwright.Frame do @moduledoc """ At any point of time, `Playwright.Page` exposes its current frame tree via - the `Playwright.Page.main_frame/1` and `Playwright.Frame.child_frames/1` - functions. + the page's main frame and frame child frames functions. A `Frame` instance lifecycle is governed by three events, dispatched on the `Playwright.Page` resource: @@ -19,6 +18,8 @@ defmodule Playwright.Frame do alias Playwright.SDK.{ChannelOwner, Helpers} @property :load_states + @property :name + @property :parent_frame @property :url @type evaluation_argument :: any() @@ -45,7 +46,7 @@ defmodule Playwright.Frame do end) Channel.bind(session, {:guid, frame.guid}, :navigated, fn event -> - {:patch, %{event.target | url: event.params.url}} + {:patch, %{event.target | url: event.params.url, name: event.params.name || ""}} end) {:ok, frame} @@ -56,11 +57,74 @@ defmodule Playwright.Frame do # --- - # @spec add_script_tag(Frame.t(), options()) :: ElementHandle.t() - # def add_script_tag(frame, options \\ %{}) + @doc """ + Adds a `") + messages = Page.console_messages(page) + """ + @spec console_messages(t()) :: [map()] + def console_messages(%Page{session: session, guid: guid}) do + Channel.post(session, {:guid, guid}, :console_messages) + end + @doc """ A shortcut for the main frame's `Playwright.Frame.dblclick/3`. """ @@ -266,6 +470,28 @@ defmodule Playwright.Page do main_frame(page) |> Frame.dispatch_event(selector, type, event_init, options) end + @doc """ + Drags the source element towards the target element and drops it. + + ## Options + + - `:force` - Bypass actionability checks. Defaults to `false`. + - `:trial` - Perform the drag without dropping. Useful for testing. + - `:steps` - Number of intermediate mouse positions. More steps = smoother drag. + Helpful for JS libraries (like SortableJS) that rely on mousemove events. + - `:source_position` - `%{x: n, y: n}` - Click position relative to source. + - `:target_position` - `%{x: n, y: n}` - Drop position relative to target. + - `:strict` - Throw if selector matches multiple elements. + - `:timeout` - Maximum time in milliseconds. + + ## Example + + # For libraries like SortableJS that need mouse events: + Page.drag_and_drop(page, "#item1", "#item2", %{ + force: true, + steps: 20 + }) + """ @spec drag_and_drop(Page.t(), binary(), binary(), options()) :: Page.t() def drag_and_drop(page, source, target, options \\ %{}) do with_latest(page, fn page -> @@ -275,8 +501,50 @@ defmodule Playwright.Page do # --- - # @spec emulate_media(t(), options()) :: :ok - # def emulate_media(page, options \\ %{}) + @doc """ + Emulates CSS media features on the page. + + This method changes the CSS media type and media features for the page. + Pass `nil` for any option to reset it to the default value. + + ## Arguments + + | key/name | type | description | + | ----------------- | ------------------------------------------------- | ---------------------------- | + | `:media` | `"screen"` \\| `"print"` \\| `nil` | Media type to emulate. | + | `:color_scheme` | `"dark"` \\| `"light"` \\| `"no-preference"` \\| `nil` | Color scheme to emulate. | + | `:reduced_motion` | `"reduce"` \\| `"no-preference"` \\| `nil` | Reduced motion preference. | + | `:forced_colors` | `"active"` \\| `"none"` \\| `nil` | Forced colors mode. | + | `:contrast` | `"more"` \\| `"no-preference"` \\| `nil` | Contrast preference. | + + ## Returns + + - `:ok` + + ## Example + + # Emulate dark mode + Page.emulate_media(page, %{color_scheme: "dark"}) + + # Emulate print media + Page.emulate_media(page, %{media: "print"}) + + # Reset to defaults + Page.emulate_media(page, %{color_scheme: nil, media: nil}) + """ + @spec emulate_media(t(), map()) :: :ok + def emulate_media(%Page{session: session, guid: guid}, options \\ %{}) do + params = %{ + media: normalize_media_option(options[:media]), + colorScheme: normalize_media_option(options[:color_scheme]), + reducedMotion: normalize_media_option(options[:reduced_motion]), + forcedColors: normalize_media_option(options[:forced_colors]), + contrast: normalize_media_option(options[:contrast]) + } + + Channel.post(session, {:guid, guid}, :emulate_media, params) + :ok + end # --- @@ -286,6 +554,35 @@ defmodule Playwright.Page do |> Frame.eval_on_selector(selector, expression, arg, options) end + @doc """ + Evaluates JavaScript expression on all elements matching selector. + + The expression is executed in the browser context. If the expression returns + a non-serializable value, the function returns `nil`. + + ## Arguments + + | key/name | type | description | + | ------------ | ---------- | ---------------------------------------- | + | `selector` | `binary()` | CSS selector to match elements. | + | `expression` | `binary()` | JavaScript expression to evaluate. | + | `arg` | `term()` | Optional argument to pass to expression. | + + ## Returns + + - Result of the JavaScript expression. + + ## Example + + # Get all link hrefs + hrefs = Page.eval_on_selector_all(page, "a", "elements => elements.map(e => e.href)") + """ + @spec eval_on_selector_all(t(), binary(), binary(), term()) :: term() + def eval_on_selector_all(%Page{} = page, selector, expression, arg \\ nil) do + main_frame(page) + |> Frame.eval_on_selector_all(selector, expression, arg) + end + @spec evaluate(t(), expression(), any()) :: serializable() def evaluate(page, expression, arg \\ nil) @@ -313,13 +610,96 @@ defmodule Playwright.Page do # --- - # @spec expect_request(t(), binary() | function(), options()) :: :ok - # def expect_request(page, url_or_predicate, options \\ %{}) - # ...defdelegate wait_for_request + @doc """ + Waits for a matching request and returns it. + + The request can be matched by: + - A URL glob pattern (e.g., `"**/api/users"`) + - A `Regex` (e.g., `~r/\\/api\\/users$/`) + - A function that receives the `Request` and returns a boolean + + ## Options + + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + + ## Examples + + # Wait for a request matching a glob pattern + request = Page.wait_for_request(page, "**/api/users", %{}, fn -> + Page.click(page, "button#submit") + end) + + # Wait for a request matching a regex + request = Page.wait_for_request(page, ~r/\\/api\\/users$/, %{}, fn -> + Page.click(page, "button") + end) + + # Wait for a request with custom predicate + request = Page.wait_for_request(page, fn req -> + req.method == "POST" and String.contains?(req.url, "/api") + end, %{}, fn -> + Page.click(page, "button") + end) + """ + @spec wait_for_request(t(), binary() | Regex.t() | function(), options(), function() | nil) :: + Request.t() | {:error, term()} + def wait_for_request(%Page{session: session} = page, url_or_predicate, options \\ %{}, trigger \\ nil) do + predicate = build_request_predicate(session, url_or_predicate) + timeout = Map.get(options, :timeout, 30_000) + + Channel.post(session, {:guid, page.guid}, :update_subscription, %{event: "request", enabled: true}) + + case Channel.wait(session, {:guid, context(page).guid}, :request, %{timeout: timeout, predicate: predicate}, trigger) do + %Playwright.SDK.Channel.Event{params: %{request: %{guid: guid}}} -> + Channel.find(session, {:guid, guid}) + + {:error, _} = error -> + error + end + end + + @doc """ + Waits for a matching response and returns it. + + The response can be matched by: + - A URL glob pattern (e.g., `"**/api/users"`) + - A `Regex` (e.g., `~r/\\/api\\/users$/`) + - A function that receives the `Response` and returns a boolean + + ## Options + + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + + ## Examples + + # Wait for a response matching a glob pattern + response = Page.wait_for_response(page, "**/api/users", %{}, fn -> + Page.click(page, "button#submit") + end) + + # Wait for a response with custom predicate + response = Page.wait_for_response(page, fn resp -> + resp.status == 200 and String.contains?(resp.url, "/api") + end, %{}, fn -> + Page.click(page, "button") + end) + """ + @spec wait_for_response(t(), binary() | Regex.t() | function(), options(), function() | nil) :: + Response.t() | {:error, term()} + def wait_for_response(%Page{session: session} = page, url_or_predicate, options \\ %{}, trigger \\ nil) do + predicate = build_response_predicate(session, url_or_predicate) + timeout = Map.get(options, :timeout, 30_000) + + Channel.post(session, {:guid, page.guid}, :update_subscription, %{event: "response", enabled: true}) + + case Channel.wait(session, {:guid, context(page).guid}, :response, %{timeout: timeout, predicate: predicate}, trigger) do + %Playwright.SDK.Channel.Event{params: %{response: %{guid: guid}}} -> + Channel.find(session, {:guid, guid}) - # @spec expect_response(t(), binary() | function(), options()) :: :ok - # def expect_response(page, url_or_predicate, options \\ %{}) - # ...defdelegate wait_for_response + {:error, _} = error -> + error + end + end @doc """ Adds a function called `param:name` on the `window` object of every frame in @@ -380,8 +760,53 @@ defmodule Playwright.Page do # --- - # @spec frame(t(), binary()) :: Frame.t() | nil - # def frame(page, selector) + @doc """ + Returns a frame matching the specified criteria. + + ## Arguments + + | key/name | type | description | + | -------- | ---- | ----------- | + | `selector` | `String.t()` or `map()` | Frame name or criteria map with `:name` or `:url` | + + ## Examples + + # By name (string shorthand) + Page.frame(page, "frame-name") + + # By name (explicit) + Page.frame(page, %{name: "frame-name"}) + + # By URL - glob pattern + Page.frame(page, %{url: "**/frame.html"}) + + # By URL - regex + Page.frame(page, %{url: ~r/.*frame.*/}) + + # By URL - predicate function + Page.frame(page, %{url: fn url -> String.contains?(url, "frame") end}) + + ## Returns + + - `Playwright.Frame.t()` - The matching frame + - `nil` - If no frame matches + """ + @spec frame(t(), String.t() | map()) :: Frame.t() | nil + def frame(%Page{} = page, name) when is_binary(name) do + frame(page, %{name: name}) + end + + def frame(%Page{} = page, %{name: name}) when is_binary(name) do + frames(page) + |> Enum.find(fn f -> Frame.name(f) == name end) + end + + def frame(%Page{} = page, %{url: url_pattern}) do + matcher = Helpers.URLMatcher.new(url_pattern) + + frames(page) + |> Enum.find(fn f -> Helpers.URLMatcher.matches(matcher, Frame.url(f)) end) + end # --- @@ -404,20 +829,87 @@ defmodule Playwright.Page do # --- - # @spec get_by_alt_text(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_alt_text(page, text, options \\ %{}) + @doc """ + Allows locating elements by their alt text. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `text` | param | `binary()` | Alt text to locate. | + | `:exact` | option | `boolean()`| Whether to find an exact match. Default to false. | + """ + @spec get_by_alt_text(t(), binary(), %{optional(:exact) => boolean()}) :: Playwright.Locator.t() + def get_by_alt_text(page, text, options \\ %{}) when is_binary(text) do + main_frame(page) |> Frame.get_by_alt_text(text, options) + end + + @doc """ + Allows locating elements by their associated label text. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `text` | param | `binary()` | Label text to locate. | + | `:exact` | option | `boolean()`| Whether to find an exact match. Default to false. | + """ + @spec get_by_label(t(), binary(), %{optional(:exact) => boolean()}) :: Playwright.Locator.t() + def get_by_label(page, text, options \\ %{}) when is_binary(text) do + main_frame(page) |> Frame.get_by_label(text, options) + end + + @doc """ + Allows locating input elements by their placeholder text. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `text` | param | `binary()` | Placeholder text to locate. | + | `:exact` | option | `boolean()`| Whether to find an exact match. Default to false. | + """ + @spec get_by_placeholder(t(), binary(), %{optional(:exact) => boolean()}) :: Playwright.Locator.t() + def get_by_placeholder(page, text, options \\ %{}) when is_binary(text) do + main_frame(page) |> Frame.get_by_placeholder(text, options) + end + + @doc """ + Allows locating elements by ARIA role. + + ## Arguments - # @spec get_by_label(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_label(page, text, options \\ %{}) + | key/name | type | | description | + | ---------------- | ------ | ---------- | ----------- | + | `role` | param | `binary()` | ARIA role (e.g., "button", "heading"). | + | `:name` | option | `binary()` | Filter by accessible name. | + | `:exact` | option | `boolean()`| Exact name match. Default to false. | + | `:checked` | option | `boolean()`| Filter by checked state. | + | `:disabled` | option | `boolean()`| Filter by disabled state. | + | `:expanded` | option | `boolean()`| Filter by expanded state. | + | `:include_hidden`| option | `boolean()`| Include hidden elements. | + | `:level` | option | `integer()`| Heading level (1-6). | + | `:pressed` | option | `boolean()`| Filter by pressed state. | + | `:selected` | option | `boolean()`| Filter by selected state. | + """ + @spec get_by_role(t(), binary(), map()) :: Playwright.Locator.t() + def get_by_role(page, role, options \\ %{}) when is_binary(role) do + main_frame(page) |> Frame.get_by_role(role, options) + end - # @spec get_by_placeholder(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_placeholder(page, text, options \\ %{}) + @doc """ + Allows locating elements by their test id attribute (data-testid by default). - # @spec get_by_role(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_role(page, text, options \\ %{}) + ## Arguments - # @spec get_by_test_id(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_test_id(page, text, options \\ %{}) + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `test_id` | param | `binary()` | The test id to locate. | + """ + @spec get_by_test_id(t(), binary()) :: Playwright.Locator.t() + def get_by_test_id(page, test_id) when is_binary(test_id) do + main_frame(page) |> Frame.get_by_test_id(test_id) + end @doc """ Allows locating elements that contain given text. @@ -434,14 +926,68 @@ defmodule Playwright.Page do main_frame(page) |> Frame.get_by_text(text, options) end - # @spec get_by_title(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_title(page, text, options \\ %{}) + @doc """ + Allows locating elements by their title attribute. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `text` | param | `binary()` | Title text to locate. | + | `:exact` | option | `boolean()`| Whether to find an exact match. Default to false. | + """ + @spec get_by_title(t(), binary(), %{optional(:exact) => boolean()}) :: Playwright.Locator.t() + def get_by_title(page, text, options \\ %{}) when is_binary(text) do + main_frame(page) |> Frame.get_by_title(text, options) + end + + @doc """ + Navigate to the previous page in history. + + ## Options + + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + - `:wait_until` - When to consider navigation succeeded. Defaults to `"load"`. + - `"load"` - wait for the load event + - `"domcontentloaded"` - wait for DOMContentLoaded event + - `"networkidle"` - wait until no network connections for 500ms + - `"commit"` - wait for network response and document started loading + + ## Returns + + - `Playwright.Response.t()` - Response of the main resource + - `nil` - if navigation did not happen (e.g., no previous page) + """ + @spec go_back(t(), options()) :: Response.t() | nil + def go_back(%Page{session: session} = page, options \\ %{}) do + case Channel.post(session, {:guid, page.guid}, :goBack, options) do + %{response: nil} -> nil + %{response: %{guid: _} = response} -> Channel.find(session, {:guid, response.guid}) + other -> other + end + end + + @doc """ + Navigate to the next page in history. - # @spec go_back(t(), options()) :: Response.t() | nil - # def go_back(page, options \\ %{}) + ## Options - # @spec go_forward(t(), options()) :: Response.t() | nil - # def go_forward(page, options \\ %{}) + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + - `:wait_until` - When to consider navigation succeeded. Defaults to `"load"`. + + ## Returns + + - `Playwright.Response.t()` - Response of the main resource + - `nil` - if navigation did not happen (e.g., no next page) + """ + @spec go_forward(t(), options()) :: Response.t() | nil + def go_forward(%Page{session: session} = page, options \\ %{}) do + case Channel.post(session, {:guid, page.guid}, :goForward, options) do + %{response: nil} -> nil + %{response: %{guid: _} = response} -> Channel.find(session, {:guid, response.guid}) + other -> other + end + end # --- @@ -469,16 +1015,84 @@ defmodule Playwright.Page do Playwright.Locator.new(page, selector) end - # @spec main_frame(t()) :: Frame.t() - # def main_frame(page) - - # @spec opener(t()) :: Frame.t() | nil - # def opener(page) + @doc """ + Returns a FrameLocator for a frame on the page. - # @spec pause(t()) :: :ok - # def pause(page) + When working with iframes, you can create a frame locator that will enter the iframe + and allow locating elements in that iframe. - # --- + ## Example + + page + |> Page.frame_locator("#my-frame") + |> FrameLocator.get_by_role("button", name: "Submit") + |> Locator.click() + """ + @spec frame_locator(t(), selector()) :: Playwright.Page.FrameLocator.t() + def frame_locator(%Page{} = page, selector) do + Playwright.Page.FrameLocator.new(main_frame(page), selector) + end + + # @spec main_frame(t()) :: Frame.t() + # def main_frame(page) + + @doc """ + Returns the page that opened this popup, or nil. + + Popup pages are opened by `window.open()` from JavaScript or by clicking + a link with `target="_blank"`. + + ## Returns + + - `Page.t()` - The opener page + - `nil` - If this page was not opened as a popup + """ + @spec opener(t()) :: t() | nil + def opener(%Page{session: session, initializer: %{opener: %{guid: guid}}}) do + Channel.find(session, {:guid, guid}) + end + + def opener(%Page{}), do: nil + + @doc """ + Returns up to 200 last page errors from this page. + + Page errors are uncaught exceptions thrown in the page's JavaScript. + + ## Returns + + - `[map()]` - List of error data + + ## Example + + Page.goto(page, "data:text/html,") + errors = Page.page_errors(page) + """ + @spec page_errors(t()) :: [map()] + def page_errors(%Page{session: session, guid: guid}) do + Channel.post(session, {:guid, guid}, :page_errors) + end + + @doc """ + Pauses script execution and opens the Playwright Inspector. + + This is useful for debugging. The page will be paused until the user + resumes from the Inspector or closes it. + + Note: This method is primarily useful in headed mode where the Inspector + window can be displayed. + """ + @spec pause(t()) :: :ok | {:error, term()} + def pause(%Page{} = page) do + ctx = context(page) + + case Channel.post(ctx.session, {:guid, ctx.guid}, :pause, %{}) do + {:error, _} = error -> error + _ -> :ok + end + end + + # --- # on(...): # - close @@ -499,24 +1113,34 @@ defmodule Playwright.Page do # - worker def on(%Page{} = page, event, callback) when is_binary(event) do - on(page, String.to_atom(event), callback) + atom = String.to_atom(event) + + if atom in @valid_events do + on(page, atom, callback) + else + {:error, %ArgumentError{message: "Invalid Page event: #{event}"}} + end end - # NOTE: These events will be recv'd from Playwright server with the parent - # BrowserContext as the context/bound :guid. So, we need to add our handlers - # there, on that (BrowserContext) parent. + # NOTE: These events are recv'd from Playwright server via the parent + # BrowserContext channel. So, we need to add our handlers there. # # For :update_subscription, :event is one of: - # (console|dialog|fileChooser|request|response|requestFinished|requestFailed) + # (console|dialog|request|response|requestFinished|requestFailed) def on(%Page{session: session} = page, event, callback) - when event in [:console, :dialog, :file_chooser, :request, :response, :request_finished, :request_failed] do - # HACK! + when event in [:console, :dialog, :request, :response, :request_finished, :request_failed] do e = Atom.to_string(event) |> Recase.to_camel() Channel.post(session, {:guid, page.guid}, :update_subscription, %{event: e, enabled: true}) Channel.bind(session, {:guid, context(page).guid}, event, callback) end + # NOTE: FileChooser events are recv'd directly on the Page channel. + def on(%Page{session: session} = page, :file_chooser, callback) do + Channel.post(session, {:guid, page.guid}, :update_subscription, %{event: "fileChooser", enabled: true}) + Channel.bind(session, {:guid, page.guid}, :file_chooser, callback) + end + def on(%Page{session: session} = page, event, callback) when is_atom(event) do Channel.bind(session, {:guid, page.guid}, event, callback) end @@ -563,7 +1187,7 @@ defmodule Playwright.Page do | key/name | type | | description | | ------------- | ------ | ---------- | ----------- | - | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2`. `(default: 30 seconds)` | + | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via BrowserContext or Page timeout settings. `(default: 30 seconds)` | | `:wait_until` | option | `binary()` | "load", "domcontentloaded", "networkidle", or "commit". When to consider the operation as having succeeded. `(default: "load")` | ## On Wait Events @@ -580,18 +1204,66 @@ defmodule Playwright.Page do # --- - # @spec remove_locator_handler(t(), Locator.t()) :: :ok - # def remove_locator_handler(page, locator) + @doc """ + Removes a previously registered locator handler. + + Removes all handlers registered for the given locator (matched by selector). + + ## Arguments + + | key/name | type | description | + | --------- | ------------- | ---------------------------- | + | `locator` | `Locator.t()` | The locator to stop handling | + + ## Returns + + - `:ok` + + ## Example + + dialog = Page.locator(page, "#cookie-dialog") + Page.add_locator_handler(page, dialog, fn _loc -> ... end) + + # Later, remove the handler + Page.remove_locator_handler(page, dialog) + """ + @spec remove_locator_handler(t(), Playwright.Locator.t()) :: :ok + def remove_locator_handler(%Page{session: session, guid: guid}, %Playwright.Locator{} = locator) do + handlers = Playwright.LocatorHandlers.find_by_selector(guid, locator.selector) + + for {uid, _data} <- handlers do + Playwright.LocatorHandlers.delete(guid, uid) + Channel.post(session, {:guid, guid}, :unregister_locator_handler, %{uid: uid}) + end + + :ok + end # --- @spec request(t()) :: Playwright.APIRequestContext.t() def request(%Page{session: session} = page) do - Channel.list(session, {:guid, page.owned_context.browser.guid}, "APIRequestContext") + # Fetch latest page state to get patched owned_context field + fresh_page = Channel.find(session, {:guid, page.guid}) + + Channel.list(session, {:guid, fresh_page.owned_context.browser.guid}, "APIRequestContext") |> List.first() end - @spec route(t(), binary(), function(), map()) :: :ok + @doc """ + Requests garbage collection in the browser. + + Useful for memory testing scenarios. + """ + @spec request_gc(t()) :: :ok | {:error, term()} + def request_gc(%Page{session: session, guid: guid}) do + case Channel.post(session, {:guid, guid}, :requestGC, %{}) do + {:error, _} = error -> error + _ -> :ok + end + end + + @spec route(t(), binary() | Regex.t(), function(), map()) :: t() | {:error, term()} def route(page, pattern, handler, options \\ %{}) def route(%Page{session: session} = page, pattern, handler, _options) do @@ -614,6 +1286,63 @@ defmodule Playwright.Page do # --- + @doc """ + Routes WebSocket connections matching the URL pattern to the handler. + + The handler receives a `Playwright.WebSocketRoute` that can be used to + intercept, mock, or modify WebSocket communication. + + ## Example + + # Mock all WebSocket connections + Page.route_web_socket(page, "**/*", fn ws -> + # Don't connect to server, just handle locally + WebSocketRoute.on_message(ws, fn msg -> + # Echo messages back + WebSocketRoute.send(ws, "Echo: \#{msg}") + end) + end) + + # Proxy with logging + Page.route_web_socket(page, "**/ws", fn ws -> + server = WebSocketRoute.connect_to_server(ws) + + WebSocketRoute.on_message(ws, fn msg -> + IO.puts("Page -> Server: \#{msg}") + WebSocketRoute.Server.send(server, msg) + end) + + WebSocketRoute.Server.on_message(server, fn msg -> + IO.puts("Server -> Page: \#{msg}") + WebSocketRoute.send(ws, msg) + end) + end) + + ## Arguments + + | key/name | type | description | + | --------- | ------------------------ | ----------- | + | `page` | `t()` | The page | + | `pattern` | `binary()` or `Regex.t()` | URL pattern to match | + | `handler` | `function()` | Handler receiving WebSocketRoute | + """ + @spec route_web_socket(t(), binary() | Regex.t(), (Playwright.WebSocketRoute.t() -> any())) :: + t() | {:error, term()} + def route_web_socket(%Page{session: session} = page, pattern, handler) when is_function(handler, 1) do + with_latest(page, fn page -> + matcher = Helpers.URLMatcher.new(pattern) + ws_handler = Helpers.WebSocketRouteHandler.new(matcher, handler) + + websocket_routes = [ws_handler | page.websocket_routes] + patterns = Helpers.WebSocketRouteHandler.prepare(websocket_routes) + + Channel.patch(session, {:guid, page.guid}, %{websocket_routes: websocket_routes}) + Channel.post(session, {:guid, page.guid}, :set_web_socket_interception_patterns, %{patterns: patterns}) + end) + end + + # --- + @spec screenshot(t(), options()) :: binary() def screenshot(%Page{session: session} = page, options \\ %{}) do case Map.pop(options, :path) do @@ -629,6 +1358,117 @@ defmodule Playwright.Page do end end + @doc """ + Generates a PDF of the page. + + Only supported in Chromium headless mode. + + ## Options + + - `:scale` - Scale of the webpage rendering. Default: `1`. + - `:display_header_footer` - Display header and footer. Default: `false`. + - `:header_template` - HTML template for the print header. + - `:footer_template` - HTML template for the print footer. + - `:print_background` - Print background graphics. Default: `false`. + - `:landscape` - Paper orientation. Default: `false`. + - `:page_ranges` - Paper ranges to print, e.g., `"1-5, 8, 11-13"`. + - `:format` - Paper format. If set, takes priority over width/height. + - `:width` - Paper width, accepts values labeled with units. + - `:height` - Paper height, accepts values labeled with units. + - `:prefer_css_page_size` - Prefer page size as defined by CSS. Default: `false`. + - `:margin` - Paper margins as map with `:top`, `:right`, `:bottom`, `:left` keys. + - `:tagged` - Generate tagged (accessible) PDF. Default: `false`. + - `:outline` - Generate document outline. Default: `false`. + - `:path` - File path to save the PDF to. + """ + @spec pdf(t(), options()) :: binary() + def pdf(%Page{session: session} = page, options \\ %{}) do + {path, params} = Map.pop(options, :path) + + params = + params + |> rename_key(:display_header_footer, :displayHeaderFooter) + |> rename_key(:header_template, :headerTemplate) + |> rename_key(:footer_template, :footerTemplate) + |> rename_key(:print_background, :printBackground) + |> rename_key(:page_ranges, :pageRanges) + |> rename_key(:prefer_css_page_size, :preferCSSPageSize) + + data = Channel.post(session, {:guid, page.guid}, :pdf, params) + + if path do + File.write!(path, Base.decode64!(data)) + end + + data + end + + defp rename_key(map, old_key, new_key) do + case Map.pop(map, old_key) do + {nil, map} -> map + {value, map} -> Map.put(map, new_key, value) + end + end + + defp normalize_media_option(nil), do: "no-override" + defp normalize_media_option(value), do: value + + defp build_request_predicate(session, predicate) when is_function(predicate) do + fn _resource, event -> + case event.params do + %{request: %{guid: guid}} -> + request = Channel.find(session, {:guid, guid}) + predicate.(request) + + _ -> + false + end + end + end + + defp build_request_predicate(session, url_pattern) do + matcher = Helpers.URLMatcher.new(url_pattern) + + fn _resource, event -> + case event.params do + %{request: %{guid: guid}} -> + request = Channel.find(session, {:guid, guid}) + Helpers.URLMatcher.matches(matcher, request.url) + + _ -> + false + end + end + end + + defp build_response_predicate(session, predicate) when is_function(predicate) do + fn _resource, event -> + case event.params do + %{response: %{guid: guid}} -> + response = Channel.find(session, {:guid, guid}) + predicate.(response) + + _ -> + false + end + end + end + + defp build_response_predicate(session, url_pattern) do + matcher = Helpers.URLMatcher.new(url_pattern) + + fn _resource, event -> + case event.params do + %{response: %{guid: guid}} -> + response = Channel.find(session, {:guid, guid}) + Helpers.URLMatcher.matches(matcher, response.url) + + _ -> + false + end + end + end + @doc """ A shortcut for the main frame's `Playwright.Frame.select_option/4`. """ @@ -639,8 +1479,24 @@ defmodule Playwright.Page do # --- - # @spec set_checked(t(), binary(), boolean(), options()) :: :ok - # def set_checked(page, selector, checked, options \\ %{}) + @doc """ + Sets the checked state of a checkbox or radio element. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ----------- | ------------------------------------ | + | `selector` | param | `binary()` | Selector to search for the element. | + | `checked` | param | `boolean()` | Whether to check or uncheck. | + + ## Returns + + - `:ok` + """ + @spec set_checked(t(), binary(), boolean(), options()) :: :ok + def set_checked(%Page{} = page, selector, checked, options \\ %{}) do + main_frame(page) |> Frame.set_checked(selector, checked, options) + end # --- @@ -649,22 +1505,153 @@ defmodule Playwright.Page do main_frame(page) |> Frame.set_content(html, options) end - # NOTE: these 2 are good examples of functions that should `cast` instead of `call`. - # ... - # @spec set_default_navigation_timeout(t(), number()) :: nil (???) - # def set_default_navigation_timeout(page, timeout) + # --- + + @doc """ + Sets the value of a file input element. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ----------- | ------------------------------------ | + | `selector` | param | `binary()` | Selector to search for the element. | + | `files` | param | `any()` | File path(s) or file payload(s). | + + ## Returns + + - `:ok` + """ + @spec set_input_files(t(), binary(), any(), options()) :: :ok + def set_input_files(%Page{} = page, selector, files, options \\ %{}) do + main_frame(page) |> Frame.set_input_files(selector, files, options) + end + + @doc """ + Sets the default timeout for all page operations. + + This setting will change the default maximum time for all the methods + accepting a `timeout` option. + + ## Arguments + + | key/name | type | description | + | --------- | ---------- | -------------------------------- | + | `timeout` | `number()` | Maximum time in milliseconds. | + + ## Returns + + - `:ok` + + ## Example + + Page.set_default_timeout(page, 60_000) # 60 seconds + """ + @spec set_default_timeout(t(), number()) :: :ok + def set_default_timeout(%Page{session: session, guid: guid}, timeout) do + Channel.post(session, {:guid, guid}, :set_default_timeout_no_reply, %{timeout: timeout}) + :ok + end + + @doc """ + Sets the default timeout for navigation operations. + + This setting will change the default maximum navigation time for the + following methods: `goto/3`, `go_back/2`, `go_forward/2`, `reload/2`, + `wait_for_navigation/3`. + + ## Arguments + + | key/name | type | description | + | --------- | ---------- | -------------------------------- | + | `timeout` | `number()` | Maximum time in milliseconds. | + + ## Returns + + - `:ok` + + ## Example + + Page.set_default_navigation_timeout(page, 90_000) # 90 seconds + """ + @spec set_default_navigation_timeout(t(), number()) :: :ok + def set_default_navigation_timeout(%Page{session: session, guid: guid}, timeout) do + Channel.post(session, {:guid, guid}, :set_default_navigation_timeout_no_reply, %{timeout: timeout}) + :ok + end + + @doc """ + Sets extra HTTP headers to be sent with every request. + + These headers will be merged with (and override) headers set by + `BrowserContext.set_extra_http_headers/2`. + + ## Arguments + + | key/name | type | description | + | --------- | -------- | ------------------------------------------ | + | `headers` | `map()` | Map of header names to values. | + + ## Returns + + - `:ok` + + ## Example + + Page.set_extra_http_headers(page, %{ + "Authorization" => "Bearer token123", + "X-Custom-Header" => "value" + }) + """ + @spec set_extra_http_headers(t(), map()) :: :ok + def set_extra_http_headers(%Page{session: session, guid: guid}, headers) when is_map(headers) do + header_list = Enum.map(headers, fn {name, value} -> %{name: to_string(name), value: value} end) + Channel.post(session, {:guid, guid}, :set_extra_http_headers, %{headers: header_list}) + :ok + end + + @doc """ + Removes all routes registered with `route/4`. + + ## Options + + | key/name | type | description | + | ---------- | ---------- | ------------------------------------------------ | + | `:behavior`| `binary()` | How to handle in-flight requests. One of: | + | | | `"default"` - abort in-flight requests | + | | | `"wait"` - wait for in-flight handlers | + | | | `"ignoreErrors"` - ignore handler errors | + + ## Returns + + - `:ok` + + ## Example - # @spec set_default_timeout(t(), number()) :: nil (???) - # def set_default_timeout(page, timeout) + # Add a route + Page.route(page, "**/*", fn route -> Route.abort(route) end) - # @spec set_extra_http_headers(t(), map()) :: :ok - # def set_extra_http_headers(page, headers) + # Later, remove all routes + Page.unroute_all(page) + + # Or wait for in-flight handlers to complete + Page.unroute_all(page, %{behavior: "wait"}) + """ + @spec unroute_all(t(), map()) :: :ok + def unroute_all(%Page{session: session, guid: guid}, options \\ %{}) do + params = if options[:behavior], do: %{behavior: options[:behavior]}, else: %{} + Channel.post(session, {:guid, guid}, :unroute_all, params) + Channel.patch(session, {:guid, guid}, %{routes: []}) + Channel.post(session, {:guid, guid}, :set_network_interception_patterns, %{patterns: []}) + :ok + end # --- @spec set_viewport_size(t(), dimensions()) :: :ok def set_viewport_size(%Page{session: session} = page, dimensions) do Channel.post(session, {:guid, page.guid}, :set_viewport_size, %{viewport_size: dimensions}) + Channel.patch(session, {:guid, page.guid}, %{viewport_size: dimensions}) + :ok end @spec text_content(t(), binary(), map()) :: binary() | nil @@ -672,6 +1659,51 @@ defmodule Playwright.Page do main_frame(page) |> Frame.text_content(selector, options) end + @spec inner_text(t(), binary(), map()) :: binary() + def inner_text(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.inner_text(selector, options) + end + + @spec inner_html(t(), binary(), map()) :: binary() + def inner_html(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.inner_html(selector, options) + end + + @spec input_value(t(), binary(), map()) :: binary() + def input_value(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.input_value(selector, options) + end + + @spec is_checked(t(), binary(), map()) :: boolean() + def is_checked(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.is_checked(selector, options) + end + + @spec is_disabled(t(), binary(), map()) :: boolean() + def is_disabled(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.is_disabled(selector, options) + end + + @spec is_editable(t(), binary(), map()) :: boolean() + def is_editable(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.is_editable(selector, options) + end + + @spec is_enabled(t(), binary(), map()) :: boolean() + def is_enabled(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.is_enabled(selector, options) + end + + @spec is_hidden(t(), binary(), map()) :: boolean() + def is_hidden(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.is_hidden(selector, options) + end + + @spec is_visible(t(), binary(), map()) :: boolean() + def is_visible(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.is_visible(selector, options) + end + @spec title(t()) :: binary() def title(%Page{} = page) do main_frame(page) |> Frame.title() @@ -679,6 +1711,26 @@ defmodule Playwright.Page do # --- + @doc """ + Unchecks a checkbox or radio element. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ----------- | ------------------------------------ | + | `selector` | param | `binary()` | Selector to search for the element. | + + ## Returns + + - `:ok` + """ + @spec uncheck(t(), binary(), options()) :: :ok + def uncheck(%Page{} = page, selector, options \\ %{}) do + main_frame(page) |> Frame.uncheck(selector, options) + end + + # --- + # @spec unroute(t(), function()) :: :ok # def unroute(page, handler \\ nil) @@ -694,11 +1746,31 @@ defmodule Playwright.Page do # --- - # @spec video(t()) :: Video.t() | nil - # def video(page, handler \\ nil) + @doc """ + Returns the video object for this page. + + Returns `nil` if video recording is not enabled. Video recording is enabled + by passing `record_video: %{dir: path}` option when creating a browser context. + + ## Returns + + - `Video.t()` - The video object + - `nil` - If video recording is not enabled + + ## Example - # @spec viewport_size(t()) :: dimensions() | nil - # def viewport_size(page) + context = Browser.new_context(browser, %{record_video: %{dir: "/tmp/videos"}}) + page = BrowserContext.new_page(context) + Page.goto(page, "https://example.com") + Page.close(page) + + video = Page.video(page) + if video, do: Video.save_as(video, "recording.webm") + """ + @spec video(t()) :: Playwright.Video.t() | nil + def video(%Page{guid: guid}), do: Playwright.Video.lookup(guid) + + # --- # @spec wait_for_event(t(), binary(), map()) :: map() # def wait_for_event(page, event, options \\ %{}) @@ -726,6 +1798,45 @@ defmodule Playwright.Page do wait_for_load_state(page, "load", options) end + @doc """ + Waits for the main frame to navigate to a new URL. + + Returns when the page navigates and reaches the required load state. + This is a shortcut for `Frame.wait_for_navigation/3` on the page's main frame. + + ## Options + + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + - `:wait_until` - When to consider navigation succeeded. Defaults to `"load"`. + - `:url` - URL pattern to wait for (glob, regex, or function). + + ## Examples + + # With a trigger function (recommended) + Page.wait_for_navigation(page, fn -> Page.click(page, "a") end) + + # With options and trigger + Page.wait_for_navigation(page, %{url: "**/success"}, fn -> Page.click(page, "a") end) + + ## Returns + + - `Page.t()` - The page after navigation + - `{:error, term()}` - If timeout occurs or navigation fails + """ + @spec wait_for_navigation(t(), options() | function(), function() | nil) :: t() | {:error, term()} + def wait_for_navigation(page, options_or_trigger \\ %{}, trigger \\ nil) + + def wait_for_navigation(%Page{} = page, trigger, nil) when is_function(trigger) do + wait_for_navigation(page, %{}, trigger) + end + + def wait_for_navigation(%Page{} = page, options, trigger) when is_map(options) do + case main_frame(page) |> Frame.wait_for_navigation(options, trigger) do + {:error, _} = error -> error + _frame -> page + end + end + @spec wait_for_selector(t(), binary(), map()) :: ElementHandle.t() | nil def wait_for_selector(%Page{} = page, selector, options \\ %{}) do main_frame(page) |> Frame.wait_for_selector(selector, options) @@ -733,8 +1844,37 @@ defmodule Playwright.Page do # --- - # @spec wait_for_url(Page.t(), binary(), options()) :: :ok - # def wait_for_url(page, url, options \\ %{}) + @doc """ + Wait until the page URL matches the given pattern. + + The pattern can be: + - A string with glob patterns (e.g., `"**/login"`) + - A regex (e.g., `~r/\\/login$/`) + - A function that receives URL and returns boolean + + ## Options + + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + - `:wait_until` - When to consider navigation succeeded. Defaults to `"load"`. + + ## Examples + + Page.wait_for_url(page, "**/login") + Page.wait_for_url(page, ~r/\\/dashboard$/) + Page.wait_for_url(page, fn url -> String.contains?(url, "success") end) + + ## Returns + + - `Page.t()` - The page after URL matches + - `{:error, term()}` - If timeout occurs + """ + @spec wait_for_url(t(), binary() | Regex.t() | function(), options()) :: t() | {:error, term()} + def wait_for_url(%Page{} = page, url_pattern, options \\ %{}) do + case main_frame(page) |> Frame.wait_for_url(url_pattern, options) do + {:error, _} = error -> error + _frame -> page + end + end # @spec workers(t()) :: [Worker.t()] # def workers(page) @@ -775,4 +1915,28 @@ defmodule Playwright.Page do end end) end + + defp on_web_socket_route(page, %{webSocketRoute: ws_route}) do + # ws_route is already hydrated by Event.new + + # Find first matching handler + handler = + Enum.find(page.websocket_routes, fn h -> + Helpers.WebSocketRouteHandler.matches(h, ws_route.url) + end) + + if handler do + Helpers.WebSocketRouteHandler.handle(handler, ws_route) + else + # No page handler, try context + context = page.owned_context || Channel.find(page.session, {:guid, page.parent.guid}) + + if context do + BrowserContext.handle_web_socket_route(context, ws_route) + else + # No handler at all, just connect through + Playwright.WebSocketRoute.connect_to_server(ws_route) + end + end + end end diff --git a/lib/playwright/page/coverage.ex b/lib/playwright/page/coverage.ex deleted file mode 100644 index 824377ca..00000000 --- a/lib/playwright/page/coverage.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule Playwright.Coverage do - @moduledoc false - - # @spec start_css_coverage(t(), options()) :: :ok - # def start_css_coverage(coverage, options \\ %{}) - - # @spec start_js_coverage(t(), options()) :: :ok - # def start_js_coverage(coverage, options \\ %{}) - - # @spec stop_css_coverage(t()) :: result() - # def stop_css_coverage(coverage) - - # @spec stop_js_coverage(t()) :: result() - # def stop_js_coverage(coverage) - -end diff --git a/lib/playwright/page/download.ex b/lib/playwright/page/download.ex index 5ac99516..b7f0b6f8 100644 --- a/lib/playwright/page/download.ex +++ b/lib/playwright/page/download.ex @@ -1,31 +1,95 @@ defmodule Playwright.Download do - @moduledoc false + @moduledoc """ + Download objects are dispatched by page via the `:download` event. - # @spec cancel(t()) :: :ok - # def cancel(download) + ## Example - # @spec create_read_stream(t()) :: readable() - # def create_read_stream(download) + Page.on(page, :download, fn download -> + Download.save_as(download, "/tmp/my-file.pdf") + end) + """ - # @spec delete(t()) :: :ok - # def delete(download) + alias Playwright.Artifact - # @spec failure(t()) :: nil | binary() - # def failure(download) + defstruct [:url, :suggested_filename, :page, :artifact] - # @spec page(t()) :: Page.t() - # def page(download) + @type t :: %__MODULE__{ + url: binary(), + suggested_filename: binary(), + page: Playwright.Page.t(), + artifact: Artifact.t() + } - # @spec path(t()) :: binary() - # def path(download) + @doc false + def new(page, url, suggested_filename, artifact) do + %__MODULE__{ + url: url, + suggested_filename: suggested_filename, + page: page, + artifact: artifact + } + end - # @spec save_as(t(), binary()) :: :ok - # def save_as(download, path) + @doc """ + Creates a Download from a download event. - # @spec suggested_filename(t()) :: binary() - # def suggested_filename(download) + ## Example - # @spec url(t()) :: binary() - # def url(download) + Page.on(page, :download, fn event -> + download = Download.from_event(event) + Download.save_as(download, "/tmp/file.txt") + end) + """ + @spec from_event(Playwright.SDK.Channel.Event.t()) :: t() + def from_event(%{target: page, params: params}) do + new(page, params.url, params.suggestedFilename, params.artifact) + end + @doc "Returns the download URL." + @spec url(t()) :: binary() + def url(%__MODULE__{url: url}), do: url + + @doc "Returns the suggested filename for the download." + @spec suggested_filename(t()) :: binary() + def suggested_filename(%__MODULE__{suggested_filename: name}), do: name + + @doc "Returns the page that initiated the download." + @spec page(t()) :: Playwright.Page.t() + def page(%__MODULE__{page: page}), do: page + + @doc """ + Returns the path to the downloaded file after it has finished downloading. + """ + @spec path(t()) :: binary() | {:error, term()} + def path(%__MODULE__{artifact: artifact}) do + Artifact.path_after_finished(artifact) + end + + @doc """ + Saves the download to the specified path. + """ + @spec save_as(t(), binary()) :: :ok | {:error, term()} + def save_as(%__MODULE__{artifact: artifact}, path) do + Artifact.save_as(artifact, path) + end + + @doc """ + Returns the error message if download failed, nil otherwise. + """ + @spec failure(t()) :: binary() | nil | {:error, term()} + def failure(%__MODULE__{artifact: artifact}) do + Artifact.failure(artifact) + end + + @doc "Cancels the download." + @spec cancel(t()) :: :ok | {:error, term()} + def cancel(%__MODULE__{artifact: artifact}) do + Artifact.cancel(artifact) + end + + @doc "Deletes the downloaded file." + @spec delete(t()) :: :ok | {:error, term()} + def delete(%__MODULE__{artifact: artifact}) do + Artifact.delete(artifact) + end end diff --git a/lib/playwright/page/file_chooser.ex b/lib/playwright/page/file_chooser.ex index c6e25648..f8349084 100644 --- a/lib/playwright/page/file_chooser.ex +++ b/lib/playwright/page/file_chooser.ex @@ -1,16 +1,96 @@ defmodule Playwright.FileChooser do - @moduledoc false + @moduledoc """ + FileChooser instances are dispatched by the page via the `:file_chooser` event. - # @spec element(t()) :: ElementHandle.t() - # def element(file_chooser) + ## Example - # @spec is_multiple(t()) :: boolean() - # def is_multiple(file_chooser) + Page.on(page, :file_chooser, fn event -> + file_chooser = FileChooser.from_event(event) + FileChooser.set_files(file_chooser, "/path/to/file.txt") + end) - # @spec page(t()) :: Page.t() - # def page(file_chooser) + # Or with expect_event + event = Page.expect_event(page, :file_chooser, fn -> + Page.click(page, "input[type=file]") + end) + file_chooser = FileChooser.from_event(event) + FileChooser.set_files(file_chooser, ["/path/to/file1.txt", "/path/to/file2.txt"]) + """ - # @spec set_files(t(), any(), options()) :: :ok - # def cancel(file_chooser, files, options \\ %{}) + alias Playwright.ElementHandle + defstruct [:page, :element, :is_multiple] + + @type t :: %__MODULE__{ + page: Playwright.Page.t(), + element: ElementHandle.t(), + is_multiple: boolean() + } + + @doc false + def new(page, element, is_multiple) do + %__MODULE__{ + page: page, + element: element, + is_multiple: is_multiple + } + end + + @doc """ + Creates a FileChooser from a file_chooser event. + + ## Example + + Page.on(page, :file_chooser, fn event -> + file_chooser = FileChooser.from_event(event) + FileChooser.set_files(file_chooser, "/path/to/file.txt") + end) + """ + @spec from_event(Playwright.SDK.Channel.Event.t()) :: t() + def from_event(%{target: page, params: params}) do + new(page, params.element, params.isMultiple) + end + + @doc """ + Returns the input element associated with this file chooser. + """ + @spec element(t()) :: ElementHandle.t() + def element(%__MODULE__{element: element}), do: element + + @doc """ + Returns whether this file chooser accepts multiple files. + """ + @spec is_multiple(t()) :: boolean() + def is_multiple(%__MODULE__{is_multiple: is_multiple}), do: is_multiple + + @doc """ + Returns the page this file chooser belongs to. + """ + @spec page(t()) :: Playwright.Page.t() + def page(%__MODULE__{page: page}), do: page + + @doc """ + Sets the value of the file input. + + ## Arguments + + - `files` - Single file path, list of file paths, or file payload map(s) + - `options` - Optional settings like `:timeout`, `:no_wait_after` + + ## Examples + + FileChooser.set_files(file_chooser, "/path/to/file.txt") + FileChooser.set_files(file_chooser, ["/path/to/file1.txt", "/path/to/file2.txt"]) + + # With file payload + FileChooser.set_files(file_chooser, %{ + name: "file.txt", + mimeType: "text/plain", + buffer: Base.encode64("Hello World") + }) + """ + @spec set_files(t(), binary() | [binary()] | map() | [map()], map()) :: :ok | {:error, term()} + def set_files(%__MODULE__{element: element}, files, options \\ %{}) do + ElementHandle.set_input_files(element, files, options) + end end diff --git a/lib/playwright/page/frame_locator.ex b/lib/playwright/page/frame_locator.ex index 6e1130d4..1db0117c 100644 --- a/lib/playwright/page/frame_locator.ex +++ b/lib/playwright/page/frame_locator.ex @@ -1,43 +1,225 @@ defmodule Playwright.Page.FrameLocator do - @moduledoc false + @moduledoc """ + FrameLocator represents a view to the iframe on the page. - # @spec first(t()) :: FrameLocator.t() - # def first(locator) + It captures the logic sufficient to retrieve the iframe and locate elements in that iframe. + FrameLocator can be created with either `Page.frame_locator/2` or `Frame.frame_locator/2`. - # @spec frame_locator(t(), binary()) :: FrameLocator.t() - # def frame_locator(locator, selector) + ## Examples - # @spec get_by_alt_text(t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_alt_text(locator, text, options \\ %{}) + # Locate element inside an iframe + page + |> Page.frame_locator("#my-frame") + |> FrameLocator.get_by_role("button", name: "Submit") + |> Locator.click() - # @spec get_by_label(t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_label(locator, text, options \\ %{}) + # Nested iframes + page + |> Page.frame_locator("#outer-frame") + |> FrameLocator.frame_locator("#inner-frame") + |> FrameLocator.get_by_text("Hello") + |> Locator.text_content() + """ - # @spec get_by_placeholder(t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_placeholder(locator, text, options \\ %{}) + alias Playwright.{Frame, Locator} - # @spec get_by_role(t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_role(locator, text, options \\ %{}) + @enforce_keys [:frame, :selector] + defstruct [:frame, :selector] - # @spec get_by_test_id(t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_test_id(locator, text, options \\ %{}) + @type t() :: %__MODULE__{ + frame: Frame.t(), + selector: binary() + } - # @spec get_by_text(t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_text(locator, text, options \\ %{}) + @doc false + @spec new(Frame.t(), binary()) :: t() + def new(%Frame{} = frame, selector) when is_binary(selector) do + %__MODULE__{frame: frame, selector: selector} + end - # @spec get_by_title(t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_title(locator, text, options \\ %{}) + # --------------------------------------------------------------------------- + # Chain methods (return FrameLocator) + # --------------------------------------------------------------------------- - # @spec last(t()) :: FrameLocator.t() - # def last(locator) + @doc """ + Returns locator to the first matching frame. + """ + @spec first(t()) :: t() + def first(%__MODULE__{} = frame_locator) do + %{frame_locator | selector: frame_locator.selector <> " >> nth=0"} + end - # @spec locator(t(), selector_or_locator(), options()) :: Locator.t() - # def locator(locator, selector, options \\ %{}) - # def locator(locator, locator, options \\ %{}) + @doc """ + Returns locator to the last matching frame. + """ + @spec last(t()) :: t() + def last(%__MODULE__{} = frame_locator) do + %{frame_locator | selector: frame_locator.selector <> " >> nth=-1"} + end - # @spec nth(t(), number()) :: FrameLocator.t() - # def nth(locator, index) + @doc """ + Returns locator to the n-th matching frame (zero-based). + """ + @spec nth(t(), integer()) :: t() + def nth(%__MODULE__{} = frame_locator, index) when is_integer(index) do + %{frame_locator | selector: frame_locator.selector <> " >> nth=#{index}"} + end - # @spec owner(t()) :: Locator.t() - # def owner(locator) + @doc """ + Returns a FrameLocator for a nested iframe. + + When working with nested iframes, this method allows you to locate iframes + inside the current frame. + """ + @spec frame_locator(t(), binary()) :: t() + def frame_locator(%__MODULE__{} = frame_locator, selector) when is_binary(selector) do + new_selector = "#{frame_locator.selector} >> internal:control=enter-frame >> #{selector}" + %{frame_locator | selector: new_selector} + end + + # --------------------------------------------------------------------------- + # Locator methods (return Locator) + # --------------------------------------------------------------------------- + + @doc """ + Returns a Locator for elements matching the selector inside the frame. + + The method finds an element matching the specified selector in the FrameLocator's + subtree. It also accepts `Locator` as an argument. + """ + @spec locator(t(), binary() | Locator.t()) :: Locator.t() + def locator(%__MODULE__{} = frame_locator, selector) when is_binary(selector) do + full_selector = "#{frame_locator.selector} >> internal:control=enter-frame >> #{selector}" + Locator.new(frame_locator.frame, full_selector) + end + + def locator(%__MODULE__{} = frame_locator, %Locator{selector: selector}) do + locator(frame_locator, selector) + end + + @doc """ + Returns a Locator pointing to the frame element itself. + + This is useful when you need to interact with the iframe element itself, + rather than elements inside it. + """ + @spec owner(t()) :: Locator.t() + def owner(%__MODULE__{} = frame_locator) do + Locator.new(frame_locator.frame, frame_locator.selector) + end + + # --------------------------------------------------------------------------- + # getBy* methods (return Locator) + # --------------------------------------------------------------------------- + + @doc """ + Allows locating elements by their alt text. + + ## Options + + - `:exact` - Whether to find an exact match: case-sensitive and whole-string. + Default: `false`. + """ + @spec get_by_alt_text(t(), binary(), map()) :: Locator.t() + def get_by_alt_text(%__MODULE__{} = frame_locator, text, options \\ %{}) when is_binary(text) do + locator(frame_locator, get_by_attr_selector("alt", text, options)) + end + + @doc """ + Allows locating input elements by their label text. + + ## Options + + - `:exact` - Whether to find an exact match: case-sensitive and whole-string. + Default: `false`. + """ + @spec get_by_label(t(), binary(), map()) :: Locator.t() + def get_by_label(%__MODULE__{} = frame_locator, text, options \\ %{}) when is_binary(text) do + locator(frame_locator, Locator.get_by_label_selector(text, options)) + end + + @doc """ + Allows locating input elements by their placeholder text. + + ## Options + + - `:exact` - Whether to find an exact match: case-sensitive and whole-string. + Default: `false`. + """ + @spec get_by_placeholder(t(), binary(), map()) :: Locator.t() + def get_by_placeholder(%__MODULE__{} = frame_locator, text, options \\ %{}) when is_binary(text) do + locator(frame_locator, get_by_attr_selector("placeholder", text, options)) + end + + @doc """ + Allows locating elements by their ARIA role, ARIA attributes and accessible name. + + ## Options + + - `:checked` - An attribute that is usually set by `aria-checked` or native input checkbox. + - `:disabled` - An attribute that is usually set by `aria-disabled` or `disabled`. + - `:exact` - Whether `name` is matched exactly: case-sensitive and whole-string. + - `:expanded` - An attribute that is usually set by `aria-expanded`. + - `:include_hidden` - Option to match hidden elements. + - `:level` - A number attribute that is usually present for roles `heading`, `listitem`, etc. + - `:name` - Option to match the accessible name. + - `:pressed` - An attribute that is usually set by `aria-pressed`. + - `:selected` - An attribute that is usually set by `aria-selected`. + """ + @spec get_by_role(t(), binary(), map()) :: Locator.t() + def get_by_role(%__MODULE__{} = frame_locator, role, options \\ %{}) when is_binary(role) do + locator(frame_locator, Locator.get_by_role_selector(role, options)) + end + + @doc """ + Locate element by the test id. + + By default, the `data-testid` attribute is used as a test id. + """ + @spec get_by_test_id(t(), binary()) :: Locator.t() + def get_by_test_id(%__MODULE__{} = frame_locator, test_id) when is_binary(test_id) do + locator(frame_locator, Locator.get_by_test_id_selector(test_id)) + end + + @doc """ + Allows locating elements that contain given text. + + ## Options + + - `:exact` - Whether to find an exact match: case-sensitive and whole-string. + Default: `false`. + """ + @spec get_by_text(t(), binary(), map()) :: Locator.t() + def get_by_text(%__MODULE__{} = frame_locator, text, options \\ %{}) when is_binary(text) do + locator(frame_locator, Locator.get_by_text_selector(text, options)) + end + + @doc """ + Allows locating elements by their title attribute. + + ## Options + + - `:exact` - Whether to find an exact match: case-sensitive and whole-string. + Default: `false`. + """ + @spec get_by_title(t(), binary(), map()) :: Locator.t() + def get_by_title(%__MODULE__{} = frame_locator, text, options \\ %{}) when is_binary(text) do + locator(frame_locator, get_by_attr_selector("title", text, options)) + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp get_by_attr_selector(attr_name, text, options) do + exact = Map.get(options, :exact, false) + escaped = escape_for_attribute_selector(text, exact) + "internal:attr=[#{attr_name}=#{escaped}]" + end + + defp escape_for_attribute_selector(value, exact) do + escaped = value |> String.replace("\\", "\\\\") |> String.replace("\"", "\\\"") + suffix = if exact, do: "s", else: "i" + "\"#{escaped}\"#{suffix}" + end end diff --git a/lib/playwright/page/mouse.ex b/lib/playwright/page/mouse.ex index 2e9dd318..f81e4650 100644 --- a/lib/playwright/page/mouse.ex +++ b/lib/playwright/page/mouse.ex @@ -1,22 +1,122 @@ defmodule Playwright.Mouse do - @moduledoc false + @moduledoc """ + Mouse provides methods for interacting with a virtual mouse. - # @spec click(t(), number(), number(), options()) :: :ok - # def click(mouse, x, y, options \\ %{}) + Every Page has its own Mouse, accessible via the page functions. - # @spec dblclick(t(), number(), number(), options()) :: :ok - # def dblclick(mouse, x, y, options \\ %{}) + ## Examples - # @spec down(t(), options()) :: :ok - # def down(mouse, options \\ %{}) + # Click at coordinates + Mouse.click(page, 100, 200) - # @spec move(t(), number(), number(), options()) :: :ok - # def move(mouse, x, y, options \\ %{}) + # Right-click + Mouse.click(page, 100, 200, button: "right") - # @spec up(t(), options()) :: :ok - # def up(mouse, options \\ %{}) + # Double-click + Mouse.dblclick(page, 100, 200) - # @spec wheel(t(), number(), number()) :: :ok - # def wheel(mouse, delta_x, delta_y) + # Drag and drop + Mouse.move(page, 0, 0) + Mouse.down(page) + Mouse.move(page, 100, 100, steps: 10) + Mouse.up(page) + # Scroll + Mouse.wheel(page, 0, 100) + """ + + use Playwright.SDK.ChannelOwner + alias Playwright.Page + + @type button :: String.t() + + @doc """ + Clicks at the specified coordinates. + + ## Options + + - `:button` - `"left"`, `"right"`, or `"middle"` (default: `"left"`) + - `:click_count` - Number of clicks (default: 1) + - `:delay` - Time between mousedown and mouseup in ms (default: 0) + """ + @spec click(Page.t(), number(), number(), keyword()) :: Page.t() + def click(page, x, y, options \\ []) do + params = %{x: x, y: y} + params = if options[:button], do: Map.put(params, :button, options[:button]), else: params + params = if options[:click_count], do: Map.put(params, :clickCount, options[:click_count]), else: params + params = if options[:delay], do: Map.put(params, :delay, options[:delay]), else: params + post!(page, :mouse_click, params) + end + + @doc """ + Double-clicks at the specified coordinates. + + ## Options + + - `:button` - `"left"`, `"right"`, or `"middle"` (default: `"left"`) + - `:delay` - Time between mousedown and mouseup in ms (default: 0) + """ + @spec dblclick(Page.t(), number(), number(), keyword()) :: Page.t() + def dblclick(page, x, y, options \\ []) do + click(page, x, y, Keyword.put(options, :click_count, 2)) + end + + @doc """ + Dispatches a mousedown event. + + ## Options + + - `:button` - `"left"`, `"right"`, or `"middle"` (default: `"left"`) + - `:click_count` - Number of clicks (default: 1) + """ + @spec down(Page.t(), keyword()) :: Page.t() + def down(page, options \\ []) do + params = %{} + params = if options[:button], do: Map.put(params, :button, options[:button]), else: params + params = if options[:click_count], do: Map.put(params, :clickCount, options[:click_count]), else: params + post!(page, :mouse_down, params) + end + + @doc """ + Dispatches a mouseup event. + + ## Options + + - `:button` - `"left"`, `"right"`, or `"middle"` (default: `"left"`) + - `:click_count` - Number of clicks (default: 1) + """ + @spec up(Page.t(), keyword()) :: Page.t() + def up(page, options \\ []) do + params = %{} + params = if options[:button], do: Map.put(params, :button, options[:button]), else: params + params = if options[:click_count], do: Map.put(params, :clickCount, options[:click_count]), else: params + post!(page, :mouse_up, params) + end + + @doc """ + Moves the mouse to the specified coordinates. + + ## Options + + - `:steps` - Number of intermediate mousemove events (default: 1) + """ + @spec move(Page.t(), number(), number(), keyword()) :: Page.t() + def move(page, x, y, options \\ []) do + params = %{x: x, y: y} + params = if options[:steps], do: Map.put(params, :steps, options[:steps]), else: params + post!(page, :mouse_move, params) + end + + @doc """ + Dispatches a wheel event (scroll). + + ## Parameters + + - `delta_x` - Horizontal scroll amount in pixels + - `delta_y` - Vertical scroll amount in pixels + """ + @spec wheel(Page.t(), number(), number()) :: Page.t() + def wheel(page, delta_x, delta_y) do + post!(page, :mouse_wheel, %{deltaX: delta_x, deltaY: delta_y}) + end end diff --git a/lib/playwright/page/touchscreen.ex b/lib/playwright/page/touchscreen.ex index 4ec7c2a5..27feee56 100644 --- a/lib/playwright/page/touchscreen.ex +++ b/lib/playwright/page/touchscreen.ex @@ -1,7 +1,40 @@ defmodule Playwright.Touchscreen do - @moduledoc false + @moduledoc """ + Touchscreen provides methods for dispatching touch events. - # @spec tap(t(), number(), number()) :: :ok - # def tap(touchscreen, x, y) + Touch events are dispatched on the page. To use touchscreen methods, + you typically need to enable touch emulation via browser context options. + ## Example + + # Create a context with touch enabled + context = Browser.new_context(browser, %{has_touch: true}) + page = BrowserContext.new_page(context) + + # Tap at coordinates + Touchscreen.tap(page, 100, 200) + """ + + alias Playwright.SDK.Channel + + @doc """ + Dispatches a `touchstart` and `touchend` event at the given coordinates. + + ## Arguments + + | key/name | type | description | + | -------- | ---- | ----------- | + | `page` | `Page.t()` | The page to dispatch the tap event on | + | `x` | `number()` | X coordinate relative to the viewport | + | `y` | `number()` | Y coordinate relative to the viewport | + + ## Returns + + - `:ok` + """ + @spec tap(Playwright.Page.t(), number(), number()) :: :ok + def tap(%Playwright.Page{session: session, guid: guid}, x, y) do + Channel.post(session, {:guid, guid}, :touchscreen_tap, %{x: x, y: y}) + :ok + end end diff --git a/lib/playwright/page/video.ex b/lib/playwright/page/video.ex index e51a6b42..d6c96221 100644 --- a/lib/playwright/page/video.ex +++ b/lib/playwright/page/video.ex @@ -1,13 +1,117 @@ defmodule Playwright.Video do - @moduledoc false + @moduledoc """ + Video object associated with a page. - # @spec delete(t()) :: :ok - # def delete(video) + Access video recordings when `record_video` option is enabled in browser context. - # @spec path(t()) :: binary() - # def path(video) + ## Example - # @spec save_as(t(), binary()) :: :ok - # def save_as(video, path) + context = Browser.new_context(browser, %{record_video: %{dir: "/tmp/videos"}}) + page = BrowserContext.new_page(context) + Page.goto(page, "https://example.com") + Page.close(page) + video = Page.video(page) + Video.save_as(video, "recording.webm") + """ + + alias Playwright.Artifact + + @table :playwright_videos + + defstruct [:artifact] + + @type t :: %__MODULE__{artifact: Artifact.t() | nil} + + @doc false + def ensure_table do + case :ets.whereis(@table) do + :undefined -> + :ets.new(@table, [:set, :public, :named_table]) + + _ -> + :ok + end + end + + @doc false + def store(page_guid, video) do + ensure_table() + :ets.insert(@table, {page_guid, video}) + end + + @doc false + def lookup(page_guid, timeout \\ 2000) do + ensure_table() + wait_for_video(page_guid, timeout) + end + + defp wait_for_video(page_guid, timeout) when timeout <= 0 do + case :ets.lookup(@table, page_guid) do + [{^page_guid, video}] -> video + [] -> nil + end + end + + defp wait_for_video(page_guid, timeout) do + case :ets.lookup(@table, page_guid) do + [{^page_guid, video}] -> + video + + [] -> + Process.sleep(50) + wait_for_video(page_guid, timeout - 50) + end + end + + @doc false + def delete_entry(page_guid) do + ensure_table() + :ets.delete(@table, page_guid) + end + + @doc """ + Returns the path to the video file. + + Note: Only works for local connections. For remote connections, + use `save_as/2` to save a copy locally. + + ## Returns + + - `binary()` - The file path + - `{:error, term()}` - If no video was recorded or remote connection + """ + @spec path(t()) :: binary() | {:error, term()} + def path(%__MODULE__{artifact: nil}), do: {:error, "Page did not produce any video frames"} + def path(%__MODULE__{artifact: artifact}), do: Artifact.path_after_finished(artifact) + + @doc """ + Saves the video to the specified path. + + Safe to call while video is recording or after page closes. + Works for both local and remote connections. + + ## Returns + + - `:ok` + - `{:error, term()}` - If no video was recorded + """ + @spec save_as(t(), binary()) :: :ok | {:error, term()} + def save_as(%__MODULE__{artifact: nil}, _path), do: {:error, "Page did not produce any video frames"} + def save_as(%__MODULE__{artifact: artifact}, path), do: Artifact.save_as(artifact, path) + + @doc """ + Deletes the video file. + + ## Returns + + - `:ok` + - `{:error, term()}` - If deletion fails + """ + @spec delete(t()) :: :ok | {:error, term()} + def delete(%__MODULE__{artifact: nil}), do: :ok + def delete(%__MODULE__{artifact: artifact}), do: Artifact.delete(artifact) + + @doc false + def new(artifact \\ nil), do: %__MODULE__{artifact: artifact} end diff --git a/lib/playwright/route.ex b/lib/playwright/route.ex index c3fe1ddf..25cee434 100644 --- a/lib/playwright/route.ex +++ b/lib/playwright/route.ex @@ -11,8 +11,31 @@ defmodule Playwright.Route do # --- - # @spec abort(t(), binary()) :: :ok - # def abort(route, error_code \\ nil) + @doc """ + Aborts the route's request. + + ## Arguments + + | key/name | type | description | + | ------------ | ---------- | ------------------------------------------------ | + | `error_code` | `binary()` | Optional error code (e.g., "aborted", "failed"). | + + ## Returns + + - `:ok` + + ## Example + + Page.route(page, "**/*.png", fn route, _request -> + Route.abort(route) + end) + """ + @spec abort(t(), binary() | nil) :: :ok + def abort(%Route{session: session, guid: guid}, error_code \\ nil) do + params = if error_code, do: %{errorCode: error_code}, else: %{} + Channel.post(session, {:guid, guid}, :abort, params) + :ok + end # --- diff --git a/lib/playwright/sdk/channel.ex b/lib/playwright/sdk/channel.ex index 5c168a61..df42f60f 100644 --- a/lib/playwright/sdk/channel.ex +++ b/lib/playwright/sdk/channel.ex @@ -82,11 +82,7 @@ defmodule Playwright.SDK.Channel do if predicate do with_timeout(options, fn timeout -> - task = - Task.async(fn -> - evaluate(predicate, event.target, event) - end) - + task = async_evaluate(predicate, event) Task.await(task, timeout) end) else @@ -94,6 +90,10 @@ defmodule Playwright.SDK.Channel do end end + defp async_evaluate(predicate, event) do + Task.async(fn -> evaluate(predicate, event.target, event) end) + end + defp evaluate(predicate, resource, event) do case predicate.(resource, event) do false -> diff --git a/lib/playwright/sdk/channel/catalog.ex b/lib/playwright/sdk/channel/catalog.ex index 7b02432c..10932f64 100644 --- a/lib/playwright/sdk/channel/catalog.ex +++ b/lib/playwright/sdk/channel/catalog.ex @@ -3,11 +3,10 @@ defmodule Playwright.SDK.Channel.Catalog do Provides storage and management of ChannelOwner instances. `Catalog` implements `GenServer` to maintain state, while domain logic is - expected to be handled within caller modules such as `Playwright.SDK.Channel`. + expected to be handled within caller modules such as the Channel module. """ use GenServer import Playwright.SDK.Helpers.ErrorHandling - alias Playwright.SDK.Channel defstruct [:awaiting, :storage] @@ -74,7 +73,7 @@ defmodule Playwright.SDK.Channel.Catalog do | `guid` | param | `binary()` | GUID to look up | | `:timeout` | option | `float()` | Maximum time to wait, in milliseconds. Defaults to `30_000` (30 seconds). | """ - @spec get(pid(), binary(), map()) :: struct() | {:error, Channel.Error.t()} + @spec get(pid(), binary(), map()) :: struct() | {:error, term()} def get(catalog, guid, options \\ %{}) do with_timeout(options, fn timeout -> GenServer.call(catalog, {:get, {:guid, guid}}, timeout) diff --git a/lib/playwright/sdk/channel/response.ex b/lib/playwright/sdk/channel/response.ex index 211c4f59..243077e1 100644 --- a/lib/playwright/sdk/channel/response.ex +++ b/lib/playwright/sdk/channel/response.ex @@ -77,10 +77,31 @@ defmodule Playwright.SDK.Channel.Response do value end + defp parse([{:pdf, value}], _catalog) do + value + end + + defp parse([{:snapshot, value}], _catalog) do + value + end + + defp parse([{:traceName, value}], _catalog) do + %{traceName: value} + end + defp parse([{:cookies, cookies}], _catalog) do cookies end + # Storage state response: %{cookies: [...], origins: [...]} + defp parse([{:cookies, cookies}, {:origins, origins}], _catalog) do + %{cookies: cookies, origins: origins} + end + + defp parse([{:origins, origins}, {:cookies, cookies}], _catalog) do + %{cookies: cookies, origins: origins} + end + defp parse([{:elements, value}], catalog) do Enum.map(value, fn %{guid: guid} -> Channel.Catalog.get(catalog, guid) end) end @@ -93,6 +114,23 @@ defmodule Playwright.SDK.Channel.Response do values end + # Locator handler registration returns uid + defp parse([{:uid, uid}], _catalog) do + %{uid: uid} + end + + defp parse([{:entries, entries}], _catalog) do + entries + end + + defp parse([{:messages, messages}], _catalog) do + messages + end + + defp parse([{:errors, errors}], _catalog) do + errors + end + defp parse([], _catalog) do nil end diff --git a/lib/playwright/sdk/channel_owner.ex b/lib/playwright/sdk/channel_owner.ex index 924da9a3..0c67c9a4 100644 --- a/lib/playwright/sdk/channel_owner.ex +++ b/lib/playwright/sdk/channel_owner.ex @@ -111,7 +111,8 @@ defmodule Playwright.SDK.ChannelOwner do end defp with_latest(subject, task) do - Channel.find(subject.session, {:guid, subject.guid}) |> task.() + # First call passes fresh state to task, second returns updated state + _ = Channel.find(subject.session, {:guid, subject.guid}) |> task.() Channel.find(subject.session, {:guid, subject.guid}) end end @@ -132,9 +133,8 @@ defmodule Playwright.SDK.ChannelOwner do defp module(%{type: type}) do String.to_existing_atom("Elixir.Playwright.#{type}") rescue - ArgumentError -> - message = "ChannelOwner of type #{inspect(type)} is not yet defined" - exit(message) + e in ArgumentError -> + reraise %{e | message: "ChannelOwner of type #{inspect(type)} is not yet defined"}, __STACKTRACE__ end # ChannelOwner macros diff --git a/lib/playwright/sdk/cli.ex b/lib/playwright/sdk/cli.ex index c80881b2..66db185a 100644 --- a/lib/playwright/sdk/cli.ex +++ b/lib/playwright/sdk/cli.ex @@ -8,14 +8,79 @@ defmodule Playwright.SDK.CLI do def install do Logger.info("Installing playwright browsers and dependencies") cli_path = config_cli() || default_cli() - {result, exit_status} = System.cmd(cli_path, ["install", "--with-deps"]) - Logger.info(result) - if exit_status != 0, do: raise("Failed to install playwright browsers") + + case detect_os() do + :arch_linux -> + Logger.info("Detected Arch Linux, installing dependencies via pacman") + install_arch_dependencies() + {result, exit_status} = System.cmd(cli_path, ["install", "chromium", "firefox"]) + Logger.info(result) + if exit_status != 0, do: raise("Failed to install playwright browsers") + + :ubuntu -> + Logger.info("Detected Ubuntu/Debian, using --with-deps") + {result, exit_status} = System.cmd(cli_path, ["install", "--with-deps", "chromium", "firefox"]) + Logger.info(result) + if exit_status != 0, do: raise("Failed to install playwright browsers") + + :unknown -> + Logger.warning("Unknown OS, attempting install without system dependencies") + {result, exit_status} = System.cmd(cli_path, ["install", "chromium", "firefox"]) + Logger.info(result) + if exit_status != 0, do: raise("Failed to install playwright browsers") + end end # private # ---------------------------------------------------------------------------- + defp detect_os do + cond do + File.exists?("/etc/arch-release") -> :arch_linux + File.exists?("/etc/debian_version") or File.exists?("/etc/lsb-release") -> :ubuntu + true -> :unknown + end + end + + defp install_arch_dependencies do + # Playwright browser dependencies for Arch Linux + packages = [ + "nss", + "nspr", + "atk", + "at-spi2-atk", + "cups", + "dbus", + "libxkbcommon", + "libxcomposite", + "libxdamage", + "libxrandr", + "mesa", + "pango", + "cairo", + "alsa-lib", + "libxshmfence" + ] + + Logger.info("Installing system dependencies: #{Enum.join(packages, ", ")}") + + # Check if running as root or with sudo + {result, exit_status} = + if System.get_env("EUID") == "0" or System.get_env("SUDO_USER") do + System.cmd("pacman", ["-S", "--needed", "--noconfirm" | packages], stderr_to_stdout: true) + else + Logger.warning("Not running as root, attempting with sudo") + System.cmd("sudo", ["pacman", "-S", "--needed", "--noconfirm" | packages], stderr_to_stdout: true) + end + + Logger.info(result) + + if exit_status != 0 do + Logger.error("Failed to install system dependencies") + Logger.warning("You may need to install these packages manually: sudo pacman -S #{Enum.join(packages, " ")}") + end + end + defp config_cli do Application.get_env(:playwright, LaunchOptions)[:driver_path] end diff --git a/lib/playwright/sdk/config.ex b/lib/playwright/sdk/config.ex index a0ba5485..b20aa389 100644 --- a/lib/playwright/sdk/config.ex +++ b/lib/playwright/sdk/config.ex @@ -239,20 +239,20 @@ defmodule Playwright.SDK.Config do @doc false @spec connect_options() :: connect_options - def connect_options() do + def connect_options do config_for(ConnectOptions, %Types.ConnectOptions{}) || %{} end @doc false @spec launch_options() :: map() - def launch_options() do + def launch_options do config_for(LaunchOptions, %Types.LaunchOptions{}) || %{} # |> clean() end @doc false @spec playwright_test() :: Types.PlaywrightTest - def playwright_test() do + def playwright_test do config_for(PlaywrightTest, %Types.PlaywrightTest{}) # |> Map.from_struct() end diff --git a/lib/playwright/sdk/helpers/route_handler.ex b/lib/playwright/sdk/helpers/route_handler.ex index e06da5a2..16e09b2e 100644 --- a/lib/playwright/sdk/helpers/route_handler.ex +++ b/lib/playwright/sdk/helpers/route_handler.ex @@ -40,8 +40,18 @@ defmodule Playwright.SDK.Helpers.RouteHandler do defp prepare_matcher(%URLMatcher{regex: %Regex{} = regex}) do %{ - regex_source: Regex.source(regex), - regex_flags: Regex.opts(regex) + regexSource: Regex.source(regex), + regexFlags: regex_opts_to_flags(Regex.opts(regex)) } end + + defp regex_opts_to_flags(opts) do + Enum.map_join(opts, "", fn + :caseless -> "i" + :multiline -> "m" + :dotall -> "s" + :unicode -> "u" + _ -> "" + end) + end end diff --git a/lib/playwright/sdk/helpers/web_socket_route_handler.ex b/lib/playwright/sdk/helpers/web_socket_route_handler.ex new file mode 100644 index 00000000..7aab773b --- /dev/null +++ b/lib/playwright/sdk/helpers/web_socket_route_handler.ex @@ -0,0 +1,57 @@ +defmodule Playwright.SDK.Helpers.WebSocketRouteHandler do + @moduledoc false + + alias Playwright.SDK.Helpers.{URLMatcher, WebSocketRouteHandler} + + defstruct [:matcher, :callback] + + def new(%URLMatcher{} = matcher, callback) do + %__MODULE__{ + matcher: matcher, + callback: callback + } + end + + def handle(%WebSocketRouteHandler{callback: callback}, web_socket_route) do + Task.start(fn -> + # Run the handler + callback.(web_socket_route) + # Ensure the WebSocket is opened even if handler doesn't call connect_to_server + # This allows sending messages without a real server connection + Playwright.WebSocketRoute.ensure_opened(web_socket_route) + end) + end + + def matches(%WebSocketRouteHandler{matcher: matcher}, url) do + URLMatcher.matches(matcher, url) + end + + def prepare(handlers) when is_list(handlers) do + Enum.into(handlers, [], fn handler -> + prepare_matcher(handler.matcher) + end) + end + + # Private + + defp prepare_matcher(%URLMatcher{match: match}) when is_binary(match) do + %{glob: match} + end + + defp prepare_matcher(%URLMatcher{regex: %Regex{} = regex}) do + %{ + regexSource: Regex.source(regex), + regexFlags: regex_opts_to_flags(Regex.opts(regex)) + } + end + + defp regex_opts_to_flags(opts) do + Enum.map_join(opts, "", fn + :caseless -> "i" + :multiline -> "m" + :dotall -> "s" + :unicode -> "u" + _ -> "" + end) + end +end diff --git a/lib/playwright/sdk/transport/websocket.ex b/lib/playwright/sdk/transport/websocket.ex index 69dcff3d..f9131abf 100644 --- a/lib/playwright/sdk/transport/websocket.ex +++ b/lib/playwright/sdk/transport/websocket.ex @@ -5,7 +5,8 @@ defmodule Playwright.SDK.Transport.WebSocket do defstruct([ :process, - :monitor + :monitor, + :stream_ref ]) # module API @@ -16,21 +17,22 @@ defmodule Playwright.SDK.Transport.WebSocket do with {:ok, process} <- :gun.open(to_charlist(uri.host), port(uri), %{connect_timeout: 30_000}), {:ok, _protocol} <- :gun.await_up(process, :timer.seconds(5)), - {:ok, _stream_ref} <- ws_upgrade(process, uri.path), + {:ok, stream_ref} <- ws_upgrade(process, uri.path), :ok <- wait_for_ws_upgrade() do monitor = Process.monitor(process) %__MODULE__{ process: process, - monitor: monitor + monitor: monitor, + stream_ref: stream_ref } else error -> error end end - def post(message, %{process: process}) do - :gun.ws_send(process, {:text, message}) + def post(message, %{process: process, stream_ref: stream_ref}) do + :gun.ws_send(process, stream_ref, {:text, message}) end def parse({:gun_ws, _process, _stream_ref, {:text, message}}, state) do diff --git a/lib/playwright/tracing.ex b/lib/playwright/tracing.ex index d118b404..f635c89b 100644 --- a/lib/playwright/tracing.ex +++ b/lib/playwright/tracing.ex @@ -1,6 +1,137 @@ defmodule Playwright.Tracing do @moduledoc """ - ... + Tracing provides methods for recording browser traces. + + Traces can be opened with Playwright Trace Viewer for debugging. + + ## Example + + context = Browser.new_context(browser) + tracing = BrowserContext.tracing(context) + Tracing.start(tracing, %{screenshots: true, snapshots: true}) + Page.goto(page, "https://example.com") + Tracing.stop(tracing, %{path: "trace.zip"}) """ use Playwright.SDK.ChannelOwner + alias Playwright.SDK.Channel + + @doc """ + Start tracing. + + ## Options + + - `:name` - Trace file name prefix + - `:title` - Trace title shown in viewer + - `:screenshots` - Capture screenshots (default: false) + - `:snapshots` - Capture DOM snapshots (default: false) + """ + @spec start(t(), map()) :: :ok | {:error, term()} + def start(%__MODULE__{session: session} = tracing, options \\ %{}) do + params = %{} + params = if options[:name], do: Map.put(params, :name, options[:name]), else: params + params = if options[:screenshots], do: Map.put(params, :screenshots, options[:screenshots]), else: params + params = if options[:snapshots], do: Map.put(params, :snapshots, options[:snapshots]), else: params + + case Channel.post(session, {:guid, tracing.guid}, :tracing_start, params) do + {:ok, _} -> start_chunk(tracing, options) + :ok -> start_chunk(tracing, options) + nil -> start_chunk(tracing, options) + {:error, _} = error -> error + end + end + + @doc """ + Start a new trace chunk. + + ## Options + + - `:name` - Chunk name prefix + - `:title` - Chunk title in viewer + """ + @spec start_chunk(t(), map()) :: :ok | {:error, term()} + def start_chunk(%__MODULE__{session: session} = tracing, options \\ %{}) do + params = %{} + params = if options[:name], do: Map.put(params, :name, options[:name]), else: params + params = if options[:title], do: Map.put(params, :title, options[:title]), else: params + + case Channel.post(session, {:guid, tracing.guid}, :tracing_start_chunk, params) do + %{traceName: _} -> :ok + {:ok, _} -> :ok + :ok -> :ok + {:error, _} = error -> error + end + end + + @doc """ + Stop tracing and export trace. + + ## Options + + - `:path` - File path to save trace zip + """ + @spec stop(t(), map()) :: :ok | {:error, term()} + def stop(%__MODULE__{session: session} = tracing, options \\ %{}) do + stop_chunk(tracing, options) + Channel.post(session, {:guid, tracing.guid}, :tracing_stop, %{}) + :ok + end + + @doc """ + Stop current trace chunk and export. + + ## Options + + - `:path` - File path to save trace zip + """ + @spec stop_chunk(t(), map()) :: :ok | {:error, term()} + def stop_chunk(%__MODULE__{session: session} = tracing, options \\ %{}) do + mode = if options[:path], do: "archive", else: "discard" + + case Channel.post(session, {:guid, tracing.guid}, :tracing_stop_chunk, %{mode: mode}) do + %Playwright.Artifact{} = artifact -> + if options[:path] do + Playwright.Artifact.save_as(artifact, options[:path]) + else + :ok + end + + _ -> + :ok + end + end + + @doc """ + Creates a named group in the trace. + + Groups help organize actions in the trace viewer. + + ## Options + + - `:location` - Custom location map with `:file`, `:line`, `:column` keys + """ + @spec group(t(), binary(), map()) :: :ok | {:error, term()} + def group(%__MODULE__{session: session} = tracing, name, options \\ %{}) do + params = %{name: name} + params = if options[:location], do: Map.put(params, :location, options[:location]), else: params + + case Channel.post(session, {:guid, tracing.guid}, :tracing_group, params) do + {:ok, _} -> :ok + :ok -> :ok + nil -> :ok + {:error, _} = error -> error + end + end + + @doc """ + Ends the current group in the trace. + """ + @spec group_end(t()) :: :ok | {:error, term()} + def group_end(%__MODULE__{session: session} = tracing) do + case Channel.post(session, {:guid, tracing.guid}, :tracing_group_end, %{}) do + {:ok, _} -> :ok + :ok -> :ok + nil -> :ok + {:error, _} = error -> error + end + end end diff --git a/lib/playwright/web_socket_route.ex b/lib/playwright/web_socket_route.ex new file mode 100644 index 00000000..524c9f51 --- /dev/null +++ b/lib/playwright/web_socket_route.ex @@ -0,0 +1,306 @@ +defmodule Playwright.WebSocketRoute do + @moduledoc """ + Provides methods for handling WebSocket connections during routing. + + When a WebSocket route is set up using `Page.route_web_socket/3` or + `BrowserContext.route_web_socket/3`, the handler receives a `WebSocketRoute` + instance that can be used to intercept, modify, or mock WebSocket communication. + + ## Example + + Page.route_web_socket(page, "**/ws", fn ws_route -> + # Connect to the actual server and proxy messages + server = WebSocketRoute.connect_to_server(ws_route) + + # Handle messages from the page + WebSocketRoute.on_message(ws_route, fn message -> + IO.puts("Page sent: \#{inspect(message)}") + # Forward to server + WebSocketRoute.Server.send(server, message) + end) + + # Handle messages from the server + WebSocketRoute.Server.on_message(server, fn message -> + IO.puts("Server sent: \#{inspect(message)}") + # Forward to page + WebSocketRoute.send(ws_route, message) + end) + end) + """ + + use Playwright.SDK.ChannelOwner + + @property :url + + @typedoc "A WebSocket message, either text (binary) or binary data." + @type message :: binary() + + @typedoc "A message handler callback." + @type message_handler :: (message() -> any()) + + @typedoc "A close handler callback." + @type close_handler :: (integer() | nil, binary() | nil -> any()) + + @doc """ + Sends a message to the page. + + ## Arguments + + | key/name | type | description | + | --------- | --------- | ----------- | + | `route` | `t()` | The WebSocket route | + | `message` | `binary()` | Message to send (text or binary) | + """ + @spec send(t(), message()) :: :ok | {:error, term()} + def send(%__MODULE__{session: session, guid: guid}, message) do + {msg, is_base64} = encode_message(message) + + case Channel.post(session, {:guid, guid}, :send_to_page, %{message: msg, isBase64: is_base64}) do + {:error, _} = error -> error + _ -> :ok + end + end + + @doc """ + Closes the WebSocket connection from the page side. + + ## Options + + | key/name | type | description | + | -------- | --------- | ----------- | + | `:code` | `integer()` | Close code (default: 1000) | + | `:reason` | `binary()` | Close reason | + """ + @spec close(t(), map()) :: :ok | {:error, term()} + def close(%__MODULE__{session: session, guid: guid}, options \\ %{}) do + params = %{ + code: options[:code], + reason: options[:reason], + wasClean: true + } + + case Channel.post(session, {:guid, guid}, :close_page, params) do + {:error, _} = error -> error + _ -> :ok + end + end + + @doc """ + Connects to the actual WebSocket server. + + Returns a `Playwright.WebSocketRoute.Server` struct that can be used to + interact with the server side of the connection. + """ + @spec connect_to_server(t()) :: Playwright.WebSocketRoute.Server.t() + def connect_to_server(%__MODULE__{session: session, guid: guid} = route) do + Channel.post(session, {:guid, guid}, :connect, %{}) + Playwright.WebSocketRoute.Server.new(route) + end + + @doc """ + Ensures the WebSocket is open without connecting to the server. + + This allows sending messages to the page even when not connected to a real server. + """ + @spec ensure_opened(t()) :: :ok | {:error, term()} + def ensure_opened(%__MODULE__{session: session, guid: guid}) do + case Channel.post(session, {:guid, guid}, :ensure_opened, %{}) do + {:error, _} = error -> error + _ -> :ok + end + end + + # Callbacks are stored in ETS for the route handlers + # See Playwright.WebSocketRouteHandlers module + + @doc """ + Registers a handler for messages received from the page. + + If no handler is set, messages are automatically forwarded to the server + (if connected via `connect_to_server/1`). + """ + @spec on_message(t(), message_handler()) :: :ok + def on_message(%__MODULE__{guid: guid}, handler) when is_function(handler, 1) do + Playwright.WebSocketRouteHandlers.set_page_message_handler(guid, handler) + end + + @doc """ + Registers a handler for when the page closes the WebSocket. + + The handler receives the close code and reason. + """ + @spec on_close(t(), close_handler()) :: :ok + def on_close(%__MODULE__{guid: guid}, handler) when is_function(handler, 2) do + Playwright.WebSocketRouteHandlers.set_page_close_handler(guid, handler) + end + + # ChannelOwner callback + def init(%__MODULE__{session: session} = route, _initializer) do + # Bind events for this WebSocket route + Channel.bind(session, {:guid, route.guid}, :message_from_page, fn %{params: params} -> + handle_message_from_page(route.guid, params) + :ok + end) + + Channel.bind(session, {:guid, route.guid}, :message_from_server, fn %{params: params} -> + handle_message_from_server(route.guid, params, session) + :ok + end) + + Channel.bind(session, {:guid, route.guid}, :close_page, fn %{params: params} -> + handle_close_page(route.guid, params, session) + :ok + end) + + Channel.bind(session, {:guid, route.guid}, :close_server, fn %{params: params} -> + handle_close_server(route.guid, params, session) + :ok + end) + + {:ok, route} + end + + # Private helpers + + defp encode_message(message) when is_binary(message) do + if String.valid?(message) do + {message, false} + else + {Base.encode64(message), true} + end + end + + defp decode_message(message, true), do: Base.decode64!(message) + defp decode_message(message, false), do: message + + defp handle_message_from_page(guid, %{message: message, isBase64: is_base64}) do + decoded = decode_message(message, is_base64) + + case Playwright.WebSocketRouteHandlers.get_page_message_handler(guid) do + nil -> + # No handler - auto-forward to server if connected (async to avoid deadlock) + Task.start(fn -> + Playwright.WebSocketRouteHandlers.forward_to_server(guid, message, is_base64) + end) + + handler -> + Task.start(fn -> handler.(decoded) end) + end + end + + defp handle_message_from_server(guid, %{message: message, isBase64: is_base64}, session) do + decoded = decode_message(message, is_base64) + + case Playwright.WebSocketRouteHandlers.get_server_message_handler(guid) do + nil -> + # No handler - auto-forward to page (async to avoid deadlock) + Task.start(fn -> + Channel.post(session, {:guid, guid}, :send_to_page, %{message: message, isBase64: is_base64}) + end) + + handler -> + Task.start(fn -> handler.(decoded) end) + end + end + + defp handle_close_page(guid, %{code: code, reason: reason, wasClean: was_clean}, session) do + case Playwright.WebSocketRouteHandlers.get_page_close_handler(guid) do + nil -> + # No handler - auto-forward to server (async to avoid deadlock) + Task.start(fn -> + Channel.post(session, {:guid, guid}, :close_server, %{code: code, reason: reason, wasClean: was_clean}) + end) + + handler -> + Task.start(fn -> handler.(code, reason) end) + end + end + + defp handle_close_server(guid, %{code: code, reason: reason, wasClean: was_clean}, session) do + case Playwright.WebSocketRouteHandlers.get_server_close_handler(guid) do + nil -> + # No handler - auto-forward to page (async to avoid deadlock) + Task.start(fn -> + Channel.post(session, {:guid, guid}, :close_page, %{code: code, reason: reason, wasClean: was_clean}) + end) + + handler -> + Task.start(fn -> handler.(code, reason) end) + end + + # Cleanup handlers when connection closes + Playwright.WebSocketRouteHandlers.cleanup(guid) + end +end + +defmodule Playwright.WebSocketRoute.Server do + @moduledoc """ + Represents the server side of a WebSocket route connection. + + Returned by `Playwright.WebSocketRoute.connect_to_server/1`. + """ + + defstruct [:route] + + @type t :: %__MODULE__{route: Playwright.WebSocketRoute.t()} + + @doc false + def new(route), do: %__MODULE__{route: route} + + @doc """ + Sends a message to the actual WebSocket server. + """ + @spec send(t(), binary()) :: :ok | {:error, term()} + def send(%__MODULE__{route: %{session: session, guid: guid}}, message) do + {msg, is_base64} = encode_message(message) + + case Playwright.SDK.Channel.post(session, {:guid, guid}, :send_to_server, %{ + message: msg, + isBase64: is_base64 + }) do + {:error, _} = error -> error + _ -> :ok + end + end + + @doc """ + Closes the connection to the actual WebSocket server. + """ + @spec close(t(), map()) :: :ok | {:error, term()} + def close(%__MODULE__{route: %{session: session, guid: guid}}, options \\ %{}) do + params = %{ + code: options[:code], + reason: options[:reason], + wasClean: true + } + + case Playwright.SDK.Channel.post(session, {:guid, guid}, :close_server, params) do + {:error, _} = error -> error + _ -> :ok + end + end + + @doc """ + Registers a handler for messages received from the server. + """ + @spec on_message(t(), Playwright.WebSocketRoute.message_handler()) :: :ok + def on_message(%__MODULE__{route: %{guid: guid}}, handler) when is_function(handler, 1) do + Playwright.WebSocketRouteHandlers.set_server_message_handler(guid, handler) + end + + @doc """ + Registers a handler for when the server closes the WebSocket. + """ + @spec on_close(t(), Playwright.WebSocketRoute.close_handler()) :: :ok + def on_close(%__MODULE__{route: %{guid: guid}}, handler) when is_function(handler, 2) do + Playwright.WebSocketRouteHandlers.set_server_close_handler(guid, handler) + end + + defp encode_message(message) when is_binary(message) do + if String.valid?(message) do + {message, false} + else + {Base.encode64(message), true} + end + end +end diff --git a/lib/playwright/web_socket_route_handlers.ex b/lib/playwright/web_socket_route_handlers.ex new file mode 100644 index 00000000..528abe84 --- /dev/null +++ b/lib/playwright/web_socket_route_handlers.ex @@ -0,0 +1,119 @@ +defmodule Playwright.WebSocketRouteHandlers do + @moduledoc false + # ETS-based storage for WebSocket route handlers and state. + # Used by WebSocketRoute for message and close event handling. + + alias Playwright.SDK.Channel + + @table :playwright_websocket_route_handlers + + @doc false + def ensure_table do + case :ets.whereis(@table) do + :undefined -> + :ets.new(@table, [:set, :public, :named_table]) + + _ -> + :ok + end + end + + # Page-side handlers + + @doc false + def set_page_message_handler(guid, handler) do + ensure_table() + update_handlers(guid, :page_message, handler) + end + + @doc false + def get_page_message_handler(guid) do + get_handler(guid, :page_message) + end + + @doc false + def set_page_close_handler(guid, handler) do + ensure_table() + update_handlers(guid, :page_close, handler) + end + + @doc false + def get_page_close_handler(guid) do + get_handler(guid, :page_close) + end + + # Server-side handlers + + @doc false + def set_server_message_handler(guid, handler) do + ensure_table() + update_handlers(guid, :server_message, handler) + end + + @doc false + def get_server_message_handler(guid) do + get_handler(guid, :server_message) + end + + @doc false + def set_server_close_handler(guid, handler) do + ensure_table() + update_handlers(guid, :server_close, handler) + end + + @doc false + def get_server_close_handler(guid) do + get_handler(guid, :server_close) + end + + # Connection state + + @doc false + def set_connected(guid, session) do + ensure_table() + update_handlers(guid, :session, session) + end + + @doc false + def forward_to_server(guid, message, is_base64) do + case get_handler(guid, :session) do + nil -> + :ok + + session -> + Channel.post(session, {:guid, guid}, :send_to_server, %{message: message, isBase64: is_base64}) + end + end + + # Cleanup + + @doc false + def cleanup(guid) do + ensure_table() + :ets.delete(@table, guid) + end + + # Private helpers + + defp get_handler(guid, key) do + ensure_table() + + case :ets.lookup(@table, guid) do + [{^guid, handlers}] -> Map.get(handlers, key) + [] -> nil + end + end + + defp update_handlers(guid, key, value) do + ensure_table() + + handlers = + case :ets.lookup(@table, guid) do + [{^guid, existing}] -> existing + [] -> %{} + end + + :ets.insert(@table, {guid, Map.put(handlers, key, value)}) + :ok + end +end diff --git a/lib/playwright_test/case.ex b/lib/playwright_test/case.ex index 19ae1118..49cac536 100644 --- a/lib/playwright_test/case.ex +++ b/lib/playwright_test/case.ex @@ -25,9 +25,7 @@ defmodule PlaywrightTest.Case do describe "features w/out `page` context" do @tag exclude: [:page] test "goes to a page", %{browser: browser} do - page = - browser - |> Playwright.Browser.new_page() + {:ok, page} = Playwright.Browser.new_page(browser) text = page @@ -67,7 +65,7 @@ defmodule PlaywrightTest.Case do context false -> - page = Playwright.Browser.new_page(context.browser) + {:ok, page} = Playwright.Browser.new_page(context.browser) on_exit(:ok, fn -> Playwright.Page.close(page) diff --git a/man/guides/contributing.md b/man/guides/contributing.md new file mode 100644 index 00000000..ca4b6da5 --- /dev/null +++ b/man/guides/contributing.md @@ -0,0 +1,175 @@ +# Contributing + +This document outlines known issues, improvement opportunities, and areas where contributions are welcome. + +## Critical Issues + +These should be addressed with priority: + +### Error Handling in `Browser.new_page/2` + +**File:** `lib/playwright/browser.ex:155` + +The `new_page/2` function doesn't handle errors from `new_context/2` or `BrowserContext.new_page/1`: + +```elixir +def new_page(%Browser{session: session} = browser, options) do + context = new_context(browser, options) + page = BrowserContext.new_page(context) + # crashes if context or page is an error tuple +end +``` + +**Fix:** Wrap in `with` statement to handle error tuples. + +### Unsafe Atom Creation in `Page.on/3` + +**File:** `lib/playwright/page.ex:501` + +```elixir +def on(%Page{} = page, event, callback) when is_binary(event) do + on(page, String.to_atom(event), callback) +end +``` + +**Risk:** Arbitrary string-to-atom conversion can exhaust atom table. + +**Fix:** Validate against known event atoms or use `String.to_existing_atom/1`. + +### Module Resolution Exit + +**File:** `lib/playwright/sdk/channel_owner.ex:133` + +```elixir +defp module(%{type: type}) do + String.to_existing_atom("Elixir.Playwright.#{type}") +rescue + ArgumentError -> + exit("ChannelOwner of type #{inspect(type)} is not yet defined") +end +``` + +**Fix:** Return `{:error, reason}` instead of calling `exit/1`. + +## Code Quality Improvements + +### Naive Glob Implementation + +**File:** `lib/playwright/sdk/helpers/url_matcher.ex:50` + +Current implementation only handles `**` patterns: + +```elixir +defp glob_to_regex(pattern) do + String.replace(pattern, ~r/\*{2,}/, ".*") +end +``` + +**Missing:** `*` (single segment), `?` (single char), `[abc]` (character classes). + +**Suggestion:** Use a proper glob library like [path_glob](https://github.com/jonleighton/path_glob). + +### Duplicate Channel.find Call + +**File:** `lib/playwright/sdk/channel_owner.ex:108` + +```elixir +defp with_latest(subject, task) do + Channel.find(subject.session, {:guid, subject.guid}) |> task.() + Channel.find(subject.session, {:guid, subject.guid}) # Called twice +end +``` + +**Fix:** Store result of first call and return it. + +### HACK Comments + +These indicate fragile code that may break with Playwright updates: + +| File | Line | Description | +|------|------|-------------| +| `lib/playwright/route.ex` | 24, 47 | Workaround for v1.33.0 changes | +| `lib/playwright/page.ex` | 513 | Event name conversion hack | + +## Dead Code + +These modules are empty stubs and can be removed: + +| File | Notes | +|------|-------| +| `lib/playwright/local_utils.ex` | Marked "obsolete?" - 6 lines | +| `lib/playwright/fetch_request.ex` | Marked "obsolete?" - 6 lines | + +## Unimplemented Features + +### Config Options + +**File:** `lib/playwright/sdk/config.ex` + +These options are documented but silently ignored: + +- `env` - Environment variables for browser process +- `downloads_path` - Custom downloads directory + +### Skipped Tests + +| File | Reason | +|------|--------| +| `test/api/page/accessibility_test.exs` | Needs `Page.wait_for_function` implementation | +| `test/api/browser_context/expect_test.exs` | Multiple tests unreachable | + +## TODO/FIXME Items + +| File | Line | Comment | +|------|------|---------| +| `lib/playwright/route.ex` | 22 | "figure out what's up with is_fallback" | +| `lib/playwright/browser.ex` | 159 | "handle the following, for page" | +| `lib/playwright/frame.ex` | 934 | FIXME: incorrect documentation | +| `lib/playwright/sdk/helpers/url_matcher.ex` | 49 | Replace with proper glob library | +| `lib/playwright/api_request_context.ex` | 66 | "move to APIResponse.body, probably" | +| `lib/playwright/sdk/channel/event.ex` | 14 | "consider promoting params as top-level fields" | + +## Refactoring Candidates + +Large files that could benefit from splitting: + +| File | Lines | Suggestion | +|------|-------|------------| +| `lib/playwright/locator.ex` | 1365 | Split into Locator.Input, Locator.Navigation, etc. | +| `lib/playwright/frame.ex` | 1044 | Extract common patterns | +| `lib/playwright/page.ex` | 778 | Well-organized but large | + +## Documentation Gaps + +- ~516 public functions lack `@doc` annotations +- Many SDK modules have `@moduledoc false` but could use internal docs +- Commented-out function stubs (527 total) indicate unimplemented API surface + +## Running Tests + +```bash +# Run all tests +mix test + +# Run with browser visible +PLAYWRIGHT_HEADLESS=false mix test + +# Run specific test file +mix test test/api/page_test.exs +``` + +## Code Style + +The project uses: + +- `mix format` for formatting +- `mix credo` for linting +- `mix dialyzer` for type checking + +Run all checks before submitting PRs: + +```bash +mix format --check-formatted +mix credo --strict +mix dialyzer +``` diff --git a/man/guides/feature_parity.md b/man/guides/feature_parity.md new file mode 100644 index 00000000..e416413f --- /dev/null +++ b/man/guides/feature_parity.md @@ -0,0 +1,677 @@ +# Playwright Feature Parity Tracking + +This document tracks the implementation status of Playwright features in playwright-elixir compared to the official TypeScript client. + +**Reference:** `/home/tristan/sources/playwright/packages/playwright-core/src/client/` + +**Legend:** +- [x] Implemented +- [~] Partially implemented / stubbed +- [ ] Not implemented +- [!] Priority implementation candidate + +--- + +## Page Module + +**File:** `lib/playwright/page.ex` +**Reference:** `page.ts` + +### Navigation & Loading + +| Method | Status | Notes | +|--------|--------|-------| +| `goto(url, options)` | [x] | | +| `reload(options)` | [x] | | +| `goBack(options)` | [x] | | +| `goForward(options)` | [x] | | +| `waitForLoadState(state, options)` | [x] | | +| `waitForNavigation(options)` | [x] | | +| `waitForURL(url, options)` | [x] | Polling-based implementation | +| `waitForRequest(urlOrPredicate, options)` | [x] | | +| `waitForResponse(urlOrPredicate, options)` | [x] | | +| `waitForEvent(event, options)` | [~] | As `expect_event` | +| `bringToFront()` | [x] | | + +### Content & State + +| Method | Status | Notes | +|--------|--------|-------| +| `url()` | [x] | | +| `title()` | [x] | | +| `content()` | [x] | Get page HTML | +| `setContent(html, options)` | [x] | | +| `setViewportSize(size)` | [x] | | +| `viewportSize()` | [x] | | +| `isClosed()` | [~] | Via `is_closed` field | +| `close(options)` | [x] | | +| `opener()` | [x] | | + +### Frames + +| Method | Status | Notes | +|--------|--------|-------| +| `mainFrame()` | [~] | Via `main_frame` field | +| `frames()` | [x] | | +| `frame(selector)` | [x] | Get frame by name/url | +| `frameLocator(selector)` | [x] | | + +### Locators (getBy* methods) + +| Method | Status | Notes | +|--------|--------|-------| +| `locator(selector, options)` | [x] | | +| `getByText(text, options)` | [x] | | +| `getByRole(role, options)` | [x] | | +| `getByTestId(testId)` | [x] | | +| `getByLabel(text, options)` | [x] | | +| `getByPlaceholder(text, options)` | [x] | | +| `getByAltText(text, options)` | [x] | | +| `getByTitle(text, options)` | [x] | | + +### Actions (selector-based) + +| Method | Status | Notes | +|--------|--------|-------| +| `click(selector, options)` | [x] | | +| `dblclick(selector, options)` | [x] | | +| `tap(selector, options)` | [x] | | +| `fill(selector, value, options)` | [x] | | +| `type(selector, text, options)` | [x] | Deprecated, use fill | +| `press(selector, key, options)` | [x] | | +| `hover(selector, options)` | [x] | | +| `focus(selector, options)` | [x] | | +| `selectOption(selector, values, options)` | [x] | | +| `check(selector, options)` | [x] | | +| `uncheck(selector, options)` | [x] | | +| `setChecked(selector, checked, options)` | [x] | | +| `setInputFiles(selector, files, options)` | [x] | | +| `dragAndDrop(source, target, options)` | [x] | | +| `dispatchEvent(selector, type, eventInit, options)` | [x] | | + +### Query Methods + +| Method | Status | Notes | +|--------|--------|-------| +| `textContent(selector, options)` | [x] | | +| `innerText(selector, options)` | [x] | | +| `innerHTML(selector, options)` | [x] | | +| `getAttribute(selector, name, options)` | [x] | | +| `inputValue(selector, options)` | [x] | | +| `isChecked(selector, options)` | [x] | | +| `isDisabled(selector, options)` | [x] | | +| `isEditable(selector, options)` | [x] | | +| `isEnabled(selector, options)` | [x] | | +| `isHidden(selector, options)` | [x] | | +| `isVisible(selector, options)` | [x] | | +| `waitForSelector(selector, options)` | [x] | | + +### JavaScript Evaluation + +| Method | Status | Notes | +|--------|--------|-------| +| `evaluate(expression, arg)` | [x] | | +| `evaluateHandle(expression, arg)` | [x] | | +| `evalOnSelector(selector, expression, arg)` | [x] | | +| `evalOnSelectorAll(selector, expression, arg)` | [x] | | +| `exposeFunction(name, callback)` | [x] | | +| `exposeBinding(name, callback, options)` | [x] | | +| `addInitScript(script, arg)` | [x] | | +| `addScriptTag(options)` | [x] | | +| `addStyleTag(options)` | [x] | | + +### Routing & Network + +| Method | Status | Notes | +|--------|--------|-------| +| `route(url, handler, options)` | [x] | | +| `unroute(url, handler)` | [x] | | +| `unrouteAll(options)` | [x] | | +| `routeFromHAR(har, options)` | [ ] | Requires LocalUtils | +| `routeWebSocket(url, handler)` | [x] | | +| `setExtraHTTPHeaders(headers)` | [x] | | + +### Screenshots & Media + +| Method | Status | Notes | +|--------|--------|-------| +| `screenshot(options)` | [x] | | +| `pdf(options)` | [x] | Chromium only | +| `video()` | [x] | | +| `emulateMedia(options)` | [x] | | + +### Events + +| Method | Status | Notes | +|--------|--------|-------| +| `on(event, callback)` | [x] | With event validation | +| `waitForEvent(event, options)` | [~] | As `expect_event` | +| `consoleMessages()` | [x] | `console_messages/1` - Requires Playwright > 1.49.1 | +| `pageErrors()` | [x] | `page_errors/1` - Requires Playwright > 1.49.1 | + +### Locator Handlers + +| Method | Status | Notes | +|--------|--------|-------| +| `addLocatorHandler(locator, handler, options)` | [x] | For auto-dismiss dialogs | +| `removeLocatorHandler(locator)` | [x] | | + +### Timeouts + +| Method | Status | Notes | +|--------|--------|-------| +| `setDefaultTimeout(timeout)` | [x] | | +| `setDefaultNavigationTimeout(timeout)` | [x] | | + +### Other + +| Method | Status | Notes | +|--------|--------|-------| +| `context()` | [x] | | +| `pause()` | [x] | Opens Playwright Inspector | +| `requestGC()` | [x] | `request_gc/1` | + +--- + +## Locator Module + +**File:** `lib/playwright/locator.ex` +**Reference:** `locator.ts` + +### Creation & Chaining + +| Method | Status | Notes | +|--------|--------|-------| +| `locator(selector, options)` | [x] | | +| `first()` | [x] | | +| `last()` | [x] | | +| `nth(index)` | [x] | | +| `filter(options)` | [x] | has_text, has_not_text, has, has_not, visible | +| `and(locator)` | [x] | As `and_` | +| `or(locator)` | [x] | As `or_` | +| `getByText(text, options)` | [x] | | +| `getByRole(role, options)` | [x] | | +| `getByTestId(testId)` | [x] | | +| `getByLabel(text, options)` | [x] | | +| `getByPlaceholder(text, options)` | [x] | | +| `getByAltText(text, options)` | [x] | | +| `getByTitle(text, options)` | [x] | | +| `frameLocator(selector)` | [x] | | +| `contentFrame()` | [x] | | + +### Actions + +| Method | Status | Notes | +|--------|--------|-------| +| `click(options)` | [x] | | +| `dblclick(options)` | [x] | | +| `tap(options)` | [x] | | +| `fill(value, options)` | [x] | | +| `clear(options)` | [x] | | +| `type(text, options)` | [x] | Deprecated | +| `pressSequentially(text, options)` | [x] | As `press_sequentially` | +| `press(key, options)` | [x] | | +| `hover(options)` | [x] | | +| `focus(options)` | [x] | | +| `blur(options)` | [x] | | +| `check(options)` | [x] | | +| `uncheck(options)` | [x] | | +| `setChecked(checked, options)` | [x] | | +| `selectOption(values, options)` | [x] | | +| `selectText(options)` | [x] | | +| `setInputFiles(files, options)` | [x] | | +| `dragTo(target, options)` | [x] | | +| `scrollIntoViewIfNeeded(options)` | [~] | As `scroll_into_view` | +| `dispatchEvent(type, eventInit, options)` | [x] | | +| `highlight()` | [x] | | + +### Query Methods + +| Method | Status | Notes | +|--------|--------|-------| +| `count()` | [x] | | +| `all()` | [x] | | +| `textContent(options)` | [x] | | +| `innerText(options)` | [x] | | +| `innerHTML(options)` | [x] | | +| `getAttribute(name, options)` | [x] | | +| `inputValue(options)` | [x] | | +| `boundingBox(options)` | [x] | | +| `allTextContents()` | [x] | | +| `allInnerTexts()` | [x] | | + +### State Checks + +| Method | Status | Notes | +|--------|--------|-------| +| `isChecked(options)` | [x] | | +| `isDisabled(options)` | [x] | | +| `isEditable(options)` | [x] | | +| `isEnabled(options)` | [x] | | +| `isHidden(options)` | [x] | | +| `isVisible(options)` | [x] | | + +### Evaluation + +| Method | Status | Notes | +|--------|--------|-------| +| `evaluate(expression, arg, options)` | [x] | | +| `evaluateAll(expression, arg)` | [x] | | +| `evaluateHandle(expression, arg, options)` | [x] | | + +### Screenshots & Handles + +| Method | Status | Notes | +|--------|--------|-------| +| `screenshot(options)` | [x] | | +| `elementHandle(options)` | [x] | | +| `elementHandles()` | [x] | | +| `ariaSnapshot(options)` | [x] | | + +### Waiting + +| Method | Status | Notes | +|--------|--------|-------| +| `waitFor(options)` | [x] | | + +### Other + +| Method | Status | Notes | +|--------|--------|-------| +| `page()` | [x] | | +| `describe(description)` | [ ] | Requires Playwright > 1.49.1 | + +--- + +## BrowserContext Module + +**File:** `lib/playwright/browser_context.ex` +**Reference:** `browserContext.ts` + +### Pages + +| Method | Status | Notes | +|--------|--------|-------| +| `newPage()` | [x] | | +| `pages()` | [x] | | +| `browser()` | [x] | | + +### Cookies + +| Method | Status | Notes | +|--------|--------|-------| +| `cookies(urls)` | [x] | | +| `addCookies(cookies)` | [x] | | +| `clearCookies(options)` | [x] | | + +### Permissions + +| Method | Status | Notes | +|--------|--------|-------| +| `grantPermissions(permissions, options)` | [x] | | +| `clearPermissions()` | [x] | | + +### Settings + +| Method | Status | Notes | +|--------|--------|-------| +| `setGeolocation(geolocation)` | [x] | | +| `setExtraHTTPHeaders(headers)` | [x] | | +| `setOffline(offline)` | [x] | | +| `setHTTPCredentials(credentials)` | [x] | | +| `setDefaultTimeout(timeout)` | [x] | | +| `setDefaultNavigationTimeout(timeout)` | [x] | | + +### Scripts & Bindings + +| Method | Status | Notes | +|--------|--------|-------| +| `addInitScript(script, arg)` | [x] | | +| `exposeBinding(name, callback, options)` | [x] | | +| `exposeFunction(name, callback)` | [x] | | + +### Routing + +| Method | Status | Notes | +|--------|--------|-------| +| `route(url, handler, options)` | [x] | | +| `unroute(url, handler)` | [x] | | +| `unrouteAll(options)` | [x] | | +| `routeFromHAR(har, options)` | [ ] | Requires LocalUtils | +| `routeWebSocket(url, handler)` | [x] | | + +### State + +| Method | Status | Notes | +|--------|--------|-------| +| `storageState(options)` | [x] | Saves cookies and localStorage | +| `close(options)` | [x] | | + +### Events + +| Method | Status | Notes | +|--------|--------|-------| +| `on(event, callback)` | [x] | | +| `waitForEvent(event, options)` | [~] | As `expect_event` | + +### Workers + +| Method | Status | Notes | +|--------|--------|-------| +| `backgroundPages()` | [x] | Returns empty list (stub) | +| `serviceWorkers()` | [x] | Returns empty list (stub) | + +### CDP + +| Method | Status | Notes | +|--------|--------|-------| +| `newCDPSession(page)` | [x] | | + +--- + +## Browser Module + +**File:** `lib/playwright/browser.ex` +**Reference:** `browser.ts` + +| Method | Status | Notes | +|--------|--------|-------| +| `newContext(options)` | [x] | | +| `newPage(options)` | [x] | Returns `{:ok, page}` | +| `contexts()` | [x] | | +| `close()` | [x] | | +| `isConnected()` | [x] | | +| `browserType()` | [x] | | +| `version` | [x] | Property | +| `name` | [x] | Property | +| `newBrowserCDPSession()` | [x] | Chromium only | +| `startTracing(page, options)` | [x] | Chromium only | +| `stopTracing()` | [x] | Chromium only | + +--- + +## Frame Module + +**File:** `lib/playwright/frame.ex` +**Reference:** `frame.ts` + +### Navigation + +| Method | Status | Notes | +|--------|--------|-------| +| `goto(url, options)` | [x] | | +| `waitForNavigation(options)` | [x] | | +| `waitForURL(url, options)` | [x] | | +| `waitForLoadState(state, options)` | [x] | | +| `url()` | [x] | | +| `name()` | [x] | | +| `title()` | [x] | | + +### Content + +| Method | Status | Notes | +|--------|--------|-------| +| `content()` | [x] | | +| `setContent(html, options)` | [x] | | + +### Hierarchy + +| Method | Status | Notes | +|--------|--------|-------| +| `page()` | [x] | | +| `parentFrame()` | [x] | | +| `childFrames()` | [x] | | +| `isDetached()` | [x] | | +| `frameElement()` | [x] | | +| `frameLocator(selector)` | [x] | | + +### Locators + +| Method | Status | Notes | +|--------|--------|-------| +| `locator(selector, options)` | [x] | | +| `getByText(text, options)` | [x] | | +| `getByRole(role, options)` | [x] | | +| `getByTestId(testId)` | [x] | | +| `getByLabel(text, options)` | [x] | | +| `getByPlaceholder(text, options)` | [x] | | +| `getByAltText(text, options)` | [x] | | +| `getByTitle(text, options)` | [x] | | + +### Actions + +| Method | Status | Notes | +|--------|--------|-------| +| `click(selector, options)` | [x] | | +| `dblclick(selector, options)` | [x] | | +| `tap(selector, options)` | [x] | | +| `fill(selector, value, options)` | [x] | | +| `type(selector, text, options)` | [x] | | +| `press(selector, key, options)` | [x] | | +| `hover(selector, options)` | [x] | | +| `focus(selector, options)` | [x] | | +| `check(selector, options)` | [x] | | +| `uncheck(selector, options)` | [x] | | +| `selectOption(selector, values, options)` | [x] | | +| `setInputFiles(selector, files, options)` | [x] | | +| `dragAndDrop(source, target, options)` | [x] | | +| `dispatchEvent(selector, type, eventInit, options)` | [x] | | + +### Query Methods + +| Method | Status | Notes | +|--------|--------|-------| +| `textContent(selector, options)` | [x] | | +| `innerText(selector, options)` | [x] | | +| `innerHTML(selector, options)` | [x] | | +| `getAttribute(selector, name, options)` | [x] | | +| `inputValue(selector, options)` | [x] | | +| `isChecked(selector, options)` | [x] | | +| `isDisabled(selector, options)` | [x] | | +| `isEditable(selector, options)` | [x] | | +| `isEnabled(selector, options)` | [x] | | +| `isHidden(selector, options)` | [x] | | +| `isVisible(selector, options)` | [x] | | +| `waitForSelector(selector, options)` | [x] | | +| `querySelector(selector)` | [x] | As `query_selector` | +| `querySelectorAll(selector)` | [x] | As `query_selector_all` | + +### Evaluation + +| Method | Status | Notes | +|--------|--------|-------| +| `evaluate(expression, arg)` | [x] | | +| `evaluateHandle(expression, arg)` | [x] | | +| `evalOnSelector(selector, expression, arg)` | [x] | | +| `evalOnSelectorAll(selector, expression, arg)` | [x] | | + +--- + +## Completely Stubbed/Empty Modules + +These modules exist but have no implemented methods (all commented out): + +### Mouse (`lib/playwright/page/mouse.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `click(x, y, options)` | [x] | | +| `dblclick(x, y, options)` | [x] | | +| `down(options)` | [x] | | +| `up(options)` | [x] | | +| `move(x, y, options)` | [x] | | +| `wheel(deltaX, deltaY)` | [x] | | + +### Touchscreen (`lib/playwright/page/touchscreen.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `tap(x, y)` | [x] | | + +### Dialog (`lib/playwright/dialog.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `accept(promptText)` | [x] | | +| `dismiss()` | [x] | | +| `message()` | [x] | | +| `defaultValue()` | [x] | `default_value/1` | +| `type()` | [x] | | +| `page()` | [x] | | + +### Download (`lib/playwright/page/download.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `cancel()` | [x] | | +| `delete()` | [x] | | +| `failure()` | [x] | | +| `page()` | [x] | | +| `path()` | [x] | | +| `saveAs(path)` | [x] | | +| `suggestedFilename()` | [x] | | +| `url()` | [x] | | + +### FileChooser (`lib/playwright/page/file_chooser.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `element()` | [x] | | +| `isMultiple()` | [x] | `is_multiple/1` | +| `page()` | [x] | | +| `setFiles(files, options)` | [x] | `set_files/3` via `from_event/1` | + +### Coverage (`lib/playwright/coverage.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `startJSCoverage(options)` | [x] | `start_js_coverage/2` | +| `stopJSCoverage()` | [x] | `stop_js_coverage/1` | +| `startCSSCoverage(options)` | [x] | `start_css_coverage/2` | +| `stopCSSCoverage()` | [x] | `stop_css_coverage/1` | + +### Tracing (`lib/playwright/tracing.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `start(options)` | [x] | | +| `startChunk(options)` | [x] | | +| `stop(options)` | [x] | | +| `stopChunk(options)` | [x] | | +| `group(name, options)` | [x] | | +| `groupEnd()` | [x] | | + +### FrameLocator (`lib/playwright/page/frame_locator.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `first()` | [x] | | +| `last()` | [x] | | +| `nth(index)` | [x] | | +| `frameLocator(selector)` | [x] | | +| `locator(selector)` | [x] | | +| `getByText(text, options)` | [x] | | +| `getByRole(role, options)` | [x] | | +| `getByTestId(testId)` | [x] | | +| `getByLabel(text, options)` | [x] | | +| `getByPlaceholder(text, options)` | [x] | | +| `getByAltText(text, options)` | [x] | | +| `getByTitle(text, options)` | [x] | | +| `owner()` | [x] | | + +--- + +## Missing Modules (Not Yet Created) + +### Clock (`lib/playwright/clock.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `install(options)` | [x] | | +| `fastForward(ticks)` | [x] | As `fast_forward` | +| `pauseAt(time)` | [x] | As `pause_at` | +| `resume()` | [x] | | +| `runFor(ticks)` | [x] | As `run_for` | +| `setFixedTime(time)` | [x] | As `set_fixed_time` | +| `setSystemTime(time)` | [x] | As `set_system_time` | + +### Video + +| Method | Status | Notes | +|--------|--------|-------| +| `delete()` | [x] | | +| `path()` | [x] | | +| `saveAs(path)` | [x] | | + +--- + +## Priority Implementation Roadmap + +### Phase 1: Core Navigation & Waiting (High Impact) +1. ~~`Page.goBack()` / `Page.goForward()`~~ DONE +2. ~~`Page.waitForNavigation()`~~ DONE +3. ~~`Page.waitForURL()`~~ DONE +4. ~~`Dialog.accept()` / `Dialog.dismiss()`~~ DONE + +### Phase 2: Modern Locators (Developer Experience) +1. ~~`*.getByRole()`~~ DONE +2. ~~`*.getByTestId()`~~ DONE +3. ~~`*.getByLabel()`~~ DONE +4. ~~`Locator.filter()`~~ DONE + +### Phase 3: Session & State (Testing Infrastructure) +1. ~~`BrowserContext.storageState()`~~ DONE +2. ~~`BrowserContext.setGeolocation()`~~ DONE +3. ~~`Download.saveAs()` / `Download.path()`~~ DONE + +### Phase 4: Advanced Features +1. ~~`Mouse` module~~ DONE +2. ~~`FrameLocator` module~~ DONE +3. ~~`Page.pdf()`~~ DONE +4. ~~`Tracing` module~~ DONE + +### Phase 5: Completeness +1. ~~Remaining Page query methods~~ DONE +2. ~~`FileChooser` module~~ DONE +3. `Clock` module +4. `Video` module + +--- + +## Implementation Notes + +### Event Names (from Playwright events.ts) + +```typescript +Page: { + AgentTurn: 'agentturn', + Close: 'close', + Crash: 'crash', + Console: 'console', + Dialog: 'dialog', + Download: 'download', + FileChooser: 'filechooser', + DOMContentLoaded: 'domcontentloaded', + PageError: 'pageerror', + Request: 'request', + Response: 'response', + RequestFailed: 'requestfailed', + RequestFinished: 'requestfinished', + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + FrameNavigated: 'framenavigated', + Load: 'load', + Popup: 'popup', + WebSocket: 'websocket', + Worker: 'worker', +} +``` + +### Channel Commands Reference + +When implementing new methods, refer to the protocol definitions in: +- `/home/tristan/sources/playwright/packages/protocol/src/channels.ts` + +### Testing Patterns + +Each new feature should include tests following the existing pattern in `test/api/`. diff --git a/mix.exs b/mix.exs index ad7cadae..5fbdeeac 100644 --- a/mix.exs +++ b/mix.exs @@ -17,7 +17,7 @@ defmodule Playwright.MixProject do elixirc_paths: elixirc_paths(Mix.env()), homepage_url: @source_url, package: package(), - preferred_cli_env: [credo: :test, dialyzer: :test, docs: :docs], + preferred_cli_env: [credo: :test, dialyzer: :test, docs: :docs, precommit: :test], source_url: @source_url, start_permanent: Mix.env() == :prod, version: "1.49.1-alpha.2" @@ -46,16 +46,16 @@ defmodule Playwright.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:cowlib, "~> 2.7.0"}, - {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, - {:esbuild, "~> 0.8.1", runtime: Mix.env() == :dev}, - {:ex_doc, "~> 0.34", only: :dev, runtime: false}, - {:gun, "~> 1.3.3"}, + {:cowlib, "~> 2.16"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:ex_doc, "~> 0.39", only: :dev, runtime: false}, + {:gun, "~> 2.2"}, {:jason, "~> 1.4"}, - {:mix_audit, "~> 1.0", only: [:dev, :test], runtime: false}, - {:playwright_assets, "1.49.1", only: [:test]}, - {:recase, "~> 0.7"}, + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, + {:playwright_assets, github: "tristanperalta/playwright-assets", only: [:test]}, + {:recase, "~> 0.9"}, {:elixir_uuid, "~> 1.2"} ] end @@ -84,6 +84,8 @@ defmodule Playwright.MixProject do "man/guides/browsers.md": [filename: "guides-browsers"], "man/guides/chrome-extensions.md": [filename: "guides-chrome-extensions"], "man/guides/command-line-tools.md": [filename: "guides-command-line-tools"], + "man/guides/contributing.md": [filename: "guides-contributing"], + "man/guides/feature_parity.md": [filename: "guides-feature-parity"], "man/guides/dialogs.md": [filename: "guides-dialogs"], "man/guides/downloads.md": [filename: "guides-downloads"], "man/guides/emulation.md": [filename: "guides-emulation"], @@ -159,6 +161,13 @@ defmodule Playwright.MixProject do [ "assets.build": [ "cmd echo 'NOT IMPLEMENTED'" + ], + precommit: [ + "compile --warnings-as-errors", + "format --check-formatted", + "credo --strict", + "dialyzer", + "test" ] ] end diff --git a/mix.lock b/mix.lock index b0b2aba4..5d6d0dff 100644 --- a/mix.lock +++ b/mix.lock @@ -1,31 +1,32 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, - "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e5580029080f3f1ad17436fb97b0d5ed2ed4e4815a96bac36b5a992e20f58db6"}, - "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"}, - "credo": {:hex, :credo, "1.7.6", "b8f14011a5443f2839b04def0b252300842ce7388f3af177157c86da18dfbeea", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "146f347fb9f8cbc5f7e39e3f22f70acbef51d441baa6d10169dd604bfbc55296"}, - "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, + "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, - "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, - "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, - "gun": {:hex, :gun, "1.3.3", "cf8b51beb36c22b9c8df1921e3f2bc4d2b1f68b49ad4fbc64e91875aa14e16b4", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "3106ce167f9c9723f849e4fb54ea4a4d814e3996ae243a1c828b256e749041e0"}, - "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, - "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, - "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, - "mix_audit": {:hex, :mix_audit, "1.0.1", "9dd114408961b8db214f42fee40b2f632ecd7e4fd29500403068c82c77db8361", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.8.0", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "65066bb7757078aa49faaa2f7c1e2d52f56ff6fe6cff01723dbaf5be2a75771b"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, - "playwright_assets": {:hex, :playwright_assets, "1.49.1", "22f633af14bf2c16a4dcf64c9e08c21fe6e16750705ac7767f07797faf4d5756", [:mix], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:plug, "~> 1.12", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.1.3", [hex: :plug_cowboy, repo: "hexpm", optional: false]}], "hexpm", "688727f5bcbf8d6b8b83f5febc207130c83fb7e00e83853ab8c9d75c9a1642d4"}, - "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.3", "38999a3e85e39f0e6bdfdf820761abac61edde1632cfebbacc445cdcb6ae1333", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "056f41f814dbb38ea44613e0f613b3b2b2f2c6afce64126e252837669eba84db"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, - "recase": {:hex, :recase, "0.8.0", "ec9500abee5d493d41e3cbfd7d51a4e10957a164570be0c805d5c6661b8cdbae", [:mix], [], "hexpm", "0d4b67b81e7897af77552bd1e6d6148717a4b45ec5c7b014a48b0ba9a28946b5"}, - "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "playwright_assets": {:git, "https://github.com/tristanperalta/playwright-assets.git", "4ca3cda19af20672d987189e3ac82a3e7c2364eb", []}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.5", "261f21b67aea8162239b2d6d3b4c31efde4daa22a20d80b19c2c0f21b34b270e", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "20884bf58a90ff5a5663420f5d2c368e9e15ed1ad5e911daf0916ea3c57f77ac"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "recase": {:hex, :recase, "0.9.1", "82d2e2e2d4f9e92da1ce5db338ede2e4f15a50ac1141fc082b80050b9f49d96e", [:mix], [], "hexpm", "19ba03ceb811750e6bec4a015a9f9e45d16a8b9e09187f6d72c3798f454710f3"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.8.0", "c7ff0034daf57279c2ce902788ce6fdb2445532eb4317e8df4b044209fae6832", [:mix], [{:yamerl, "~> 0.8", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "4b674bd881e373d1ac6a790c64b2ecb69d1fd612c2af3b22de1619c15473830b"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, } diff --git a/test/api/browser_context/cookies_test.exs b/test/api/browser_context/cookies_test.exs index 8332827b..4feea13b 100644 --- a/test/api/browser_context/cookies_test.exs +++ b/test/api/browser_context/cookies_test.exs @@ -4,7 +4,7 @@ defmodule Playwright.BrowserContext.CookiesTest do describe "BrowserContext.cookies/1" do test "retrieves no cookies from a pristine context", %{page: page} do - cookies = BrowserContext.cookies(page.owned_context) + cookies = BrowserContext.cookies(Page.owned_context(page)) assert cookies == [] end diff --git a/test/api/browser_context/geolocation_test.exs b/test/api/browser_context/geolocation_test.exs new file mode 100644 index 00000000..7b21a91b --- /dev/null +++ b/test/api/browser_context/geolocation_test.exs @@ -0,0 +1,85 @@ +defmodule Playwright.BrowserContext.GeolocationTest do + use Playwright.TestCase, async: true + alias Playwright.{BrowserContext, Page} + + describe "BrowserContext.set_geolocation/2" do + test "sets geolocation", %{assets: assets, page: page} do + context = Page.context(page) + + BrowserContext.grant_permissions(context, ["geolocation"]) + BrowserContext.set_geolocation(context, %{latitude: 37.7749, longitude: -122.4194}) + + Page.goto(page, assets.empty) + + # Verify via JavaScript + result = + Page.evaluate(page, """ + () => new Promise(resolve => { + navigator.geolocation.getCurrentPosition(pos => { + resolve({lat: pos.coords.latitude, lng: pos.coords.longitude}); + }); + }) + """) + + # Handle both atom and string keys + lat = Map.get(result, :lat) || Map.get(result, "lat") + lng = Map.get(result, :lng) || Map.get(result, "lng") + + assert lat == 37.7749 + assert lng == -122.4194 + end + + test "sets geolocation with accuracy", %{assets: assets, page: page} do + context = Page.context(page) + + BrowserContext.grant_permissions(context, ["geolocation"]) + + BrowserContext.set_geolocation(context, %{ + latitude: 51.5074, + longitude: -0.1278, + accuracy: 100 + }) + + Page.goto(page, assets.empty) + + result = + Page.evaluate(page, """ + () => new Promise(resolve => { + navigator.geolocation.getCurrentPosition(pos => { + resolve({accuracy: pos.coords.accuracy}); + }); + }) + """) + + accuracy = Map.get(result, :accuracy) || Map.get(result, "accuracy") + assert accuracy == 100 + end + + test "updates geolocation", %{assets: assets, page: page} do + context = Page.context(page) + + BrowserContext.grant_permissions(context, ["geolocation"]) + BrowserContext.set_geolocation(context, %{latitude: 10, longitude: 10}) + + Page.goto(page, assets.empty) + + # Update to new location + BrowserContext.set_geolocation(context, %{latitude: 20, longitude: 30}) + + result = + Page.evaluate(page, """ + () => new Promise(resolve => { + navigator.geolocation.getCurrentPosition(pos => { + resolve({lat: pos.coords.latitude, lng: pos.coords.longitude}); + }); + }) + """) + + lat = Map.get(result, :lat) || Map.get(result, "lat") + lng = Map.get(result, :lng) || Map.get(result, "lng") + + assert lat == 20 + assert lng == 30 + end + end +end diff --git a/test/api/browser_context/storage_state_test.exs b/test/api/browser_context/storage_state_test.exs index 88a85c3b..106b096d 100644 --- a/test/api/browser_context/storage_state_test.exs +++ b/test/api/browser_context/storage_state_test.exs @@ -1,7 +1,95 @@ defmodule Playwright.BrowserContext.StorageStateTest do use Playwright.TestCase, async: true + alias Playwright.{BrowserContext, Page} - # test_should_capture_local_storage - # test_should_set_local_storage - # test_should_round_trip_through_the_file + describe "BrowserContext.storage_state/1" do + test "returns cookies and origins", %{page: page} do + context = Page.context(page) + + # Set a cookie via the context + BrowserContext.add_cookies(context, [ + %{name: "test_cookie", value: "cookie_value", url: "https://example.com"} + ]) + + state = BrowserContext.storage_state(context) + + assert is_map(state) + # The state should have cookies and origins keys (as atoms or strings) + cookies = Map.get(state, :cookies) || Map.get(state, "cookies") + origins = Map.get(state, :origins) || Map.get(state, "origins") + + assert is_list(cookies) + assert is_list(origins) + + # Verify our cookie is present + assert Enum.any?(cookies, fn cookie -> + name = Map.get(cookie, :name) || Map.get(cookie, "name") + name == "test_cookie" + end) + end + + test "returns empty state for fresh context", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + + state = BrowserContext.storage_state(context) + + assert is_map(state) + cookies = Map.get(state, :cookies) || Map.get(state, "cookies") + assert is_list(cookies) + + BrowserContext.close(context) + end + + test "captures localStorage", %{assets: assets, page: page} do + context = Page.context(page) + + # Navigate to a real page and set localStorage + Page.goto(page, assets.empty) + Page.evaluate(page, "() => localStorage.setItem('test_key', 'test_value')") + + state = BrowserContext.storage_state(context) + + origins = Map.get(state, :origins) || Map.get(state, "origins") + assert is_list(origins) + + # Find the origin with our localStorage + origin_with_storage = + Enum.find(origins, fn origin -> + local_storage = Map.get(origin, :localStorage) || Map.get(origin, "localStorage") + is_list(local_storage) && local_storage != [] + end) + + assert origin_with_storage != nil + end + end + + describe "BrowserContext.storage_state/2 with path option" do + test "saves state to JSON file", %{page: page} do + context = Page.context(page) + + BrowserContext.add_cookies(context, [ + %{name: "file_test", value: "file_value", url: "https://example.com"} + ]) + + path = Path.join(System.tmp_dir!(), "storage_state_#{:rand.uniform(100_000)}.json") + + state = BrowserContext.storage_state(context, path: path) + + # Should still return the state + assert is_map(state) + + # File should exist + assert File.exists?(path) + + # File should contain valid JSON + file_content = File.read!(path) + decoded = Jason.decode!(file_content) + + assert is_map(decoded) + assert is_list(decoded["cookies"]) + + # Cleanup + File.rm!(path) + end + end end diff --git a/test/api/browser_context_test.exs b/test/api/browser_context_test.exs index c383ab0a..5326e4e3 100644 --- a/test/api/browser_context_test.exs +++ b/test/api/browser_context_test.exs @@ -21,7 +21,7 @@ defmodule Playwright.BrowserContextTest do test "creates and binds a new context", %{browser: browser} do assert Browser.contexts(browser) == [] - page = Browser.new_page(browser) + {:ok, page} = Browser.new_page(browser) assert [%BrowserContext{} = context] = Browser.contexts(browser) assert context.browser == browser @@ -272,6 +272,42 @@ defmodule Playwright.BrowserContextTest do end end + describe "BrowserContext.set_default_timeout/2" do + test "sets the default timeout", %{page: page} do + context = Page.context(page) + assert :ok = BrowserContext.set_default_timeout(context, 30_000) + end + end + + describe "BrowserContext.set_default_navigation_timeout/2" do + test "sets the default navigation timeout", %{page: page} do + context = Page.context(page) + assert :ok = BrowserContext.set_default_navigation_timeout(context, 60_000) + end + end + + describe "BrowserContext.set_extra_http_headers/2" do + test "sets extra HTTP headers", %{page: page} do + context = Page.context(page) + assert :ok = BrowserContext.set_extra_http_headers(context, %{"X-Custom" => "value"}) + end + + test "clears headers with empty map", %{page: page} do + context = Page.context(page) + BrowserContext.set_extra_http_headers(context, %{"X-Custom" => "value"}) + assert :ok = BrowserContext.set_extra_http_headers(context, %{}) + end + end + + describe "BrowserContext.set_http_credentials/2" do + test "sets and clears credentials", %{browser: browser} do + context = Browser.new_context(browser) + assert :ok = BrowserContext.set_http_credentials(context, %{username: "user", password: "pass"}) + assert :ok = BrowserContext.set_http_credentials(context, nil) + BrowserContext.close(context) + end + end + describe "User Agent" do test "can be set via new_context", %{browser: browser} do context = Browser.new_context(browser, %{"userAgent" => "Mozzies"}) @@ -283,13 +319,27 @@ defmodule Playwright.BrowserContextTest do end test "can be set via new_page", %{browser: browser} do - page = Browser.new_page(browser, %{"userAgent" => "Mozzies"}) + {:ok, page} = Browser.new_page(browser, %{"userAgent" => "Mozzies"}) assert Page.evaluate(page, "window.navigator.userAgent") == "Mozzies" Page.close(page) end end + + describe "BrowserContext.background_pages/1" do + test "returns a list", %{page: page} do + context = Page.context(page) + assert [] = BrowserContext.background_pages(context) + end + end + + describe "BrowserContext.service_workers/1" do + test "returns a list", %{page: page} do + context = Page.context(page) + assert is_list(BrowserContext.service_workers(context)) + end + end end # test_expose_function_should_throw_for_duplicate_registrations diff --git a/test/api/browser_test.exs b/test/api/browser_test.exs index d601e507..2f707004 100644 --- a/test/api/browser_test.exs +++ b/test/api/browser_test.exs @@ -1,6 +1,6 @@ defmodule Playwright.BrowserTest do use Playwright.TestCase, async: true - alias Playwright.{Browser, BrowserContext, Page} + alias Playwright.{Browser, BrowserContext, BrowserType, CDPSession, Page} describe "Browser.close/1" do @tag exclude: [:page] @@ -16,10 +16,10 @@ defmodule Playwright.BrowserTest do test "builds a new Page, incl. context", %{browser: browser} do assert [] = Browser.contexts(browser) - page1 = Browser.new_page(browser) + {:ok, page1} = Browser.new_page(browser) assert [%BrowserContext{}] = Browser.contexts(browser) - page2 = Browser.new_page(browser) + {:ok, page2} = Browser.new_page(browser) assert [%BrowserContext{}, %BrowserContext{}] = Browser.contexts(browser) Page.close(page1) @@ -50,4 +50,33 @@ defmodule Playwright.BrowserTest do end end end + + describe "Browser.is_connected/1" do + test "returns true when browser is connected", %{browser: browser} do + assert Browser.is_connected(browser) == true + end + + @tag exclude: [:page] + test "returns false after browser is closed", %{transport: transport} do + {_session, inline_browser} = setup_browser(transport) + assert Browser.is_connected(inline_browser) == true + Browser.close(inline_browser) + assert Browser.is_connected(inline_browser) == false + end + end + + describe "Browser.browser_type/1" do + test "returns the BrowserType that launched the browser", %{browser: browser} do + browser_type = Browser.browser_type(browser) + assert %BrowserType{} = browser_type + end + end + + describe "Browser.new_browser_cdp_session/1" do + test "creates a CDP session at browser level", %{browser: browser} do + session = Browser.new_browser_cdp_session(browser) + assert %CDPSession{} = session + CDPSession.detach(session) + end + end end diff --git a/test/api/browser_tracing_test.exs b/test/api/browser_tracing_test.exs new file mode 100644 index 00000000..1e323d19 --- /dev/null +++ b/test/api/browser_tracing_test.exs @@ -0,0 +1,43 @@ +defmodule Playwright.BrowserTracingTest do + use Playwright.TestCase, async: true + alias Playwright.{Browser, Page} + + describe "Browser.start_tracing/3 and Browser.stop_tracing/1" do + test "records and returns trace data", %{browser: browser, page: page, assets: assets} do + Browser.start_tracing(browser, page) + Page.goto(page, assets.empty) + trace = Browser.stop_tracing(browser) + + # Trace data should be binary with content + assert is_binary(trace) + assert byte_size(trace) > 0 + end + + test "works with screenshots option", %{browser: browser, page: page, assets: assets} do + Browser.start_tracing(browser, page, %{screenshots: true}) + Page.goto(page, assets.empty) + trace = Browser.stop_tracing(browser) + + assert is_binary(trace) + assert byte_size(trace) > 0 + end + + test "works without page parameter", %{browser: browser, page: page, assets: assets} do + Browser.start_tracing(browser) + Page.goto(page, assets.empty) + trace = Browser.stop_tracing(browser) + + assert is_binary(trace) + assert byte_size(trace) > 0 + end + + test "trace contains valid JSON data", %{browser: browser, page: page, assets: assets} do + Browser.start_tracing(browser, page) + Page.goto(page, assets.empty) + trace = Browser.stop_tracing(browser) + + # Trace should be valid JSON + assert {:ok, _decoded} = Jason.decode(trace) + end + end +end diff --git a/test/api/clock_test.exs b/test/api/clock_test.exs new file mode 100644 index 00000000..f402d1cc --- /dev/null +++ b/test/api/clock_test.exs @@ -0,0 +1,158 @@ +defmodule Playwright.ClockTest do + use Playwright.TestCase, async: true + alias Playwright.{BrowserContext, Clock, Page} + + describe "Clock.install/2" do + test "sets initial time from ISO string", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + page = BrowserContext.new_page(context) + + Clock.install(context, %{time: "2024-01-01T00:00:00Z"}) + + time = Page.evaluate(page, "() => new Date().toISOString()") + assert time =~ "2024-01-01" + + BrowserContext.close(context) + end + + test "sets initial time from number (epoch ms)", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + page = BrowserContext.new_page(context) + + # Jan 1, 2024 00:00:00 UTC in milliseconds + Clock.install(context, %{time: 1_704_067_200_000}) + + time = Page.evaluate(page, "() => new Date().toISOString()") + assert time =~ "2024-01-01" + + BrowserContext.close(context) + end + end + + describe "Clock.fast_forward/2" do + test "advances time by milliseconds", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + page = BrowserContext.new_page(context) + + Clock.install(context, %{time: 0}) + + time_before = Page.evaluate(page, "() => Date.now()") + Clock.fast_forward(context, 5000) + time_after = Page.evaluate(page, "() => Date.now()") + + assert time_after - time_before >= 5000 + + BrowserContext.close(context) + end + + test "advances time by string duration", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + page = BrowserContext.new_page(context) + + Clock.install(context, %{time: 0}) + # 1 min 30 sec = 90000ms + Clock.fast_forward(context, "00:01:30") + + time = Page.evaluate(page, "() => Date.now()") + assert time == 90_000 + + BrowserContext.close(context) + end + end + + describe "Clock.set_fixed_time/2" do + test "freezes time at specified value", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + page = BrowserContext.new_page(context) + + Clock.install(context) + Clock.set_fixed_time(context, "2024-06-15T12:00:00Z") + + time1 = Page.evaluate(page, "() => Date.now()") + Process.sleep(100) + time2 = Page.evaluate(page, "() => Date.now()") + + # Time should not advance + assert time1 == time2 + + BrowserContext.close(context) + end + end + + describe "Clock.set_system_time/2" do + test "sets time but allows it to advance", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + page = BrowserContext.new_page(context) + + Clock.install(context) + Clock.set_system_time(context, "2024-06-15T12:00:00Z") + + time1 = Page.evaluate(page, "() => Date.now()") + # Small sleep to allow time to advance + Process.sleep(50) + time2 = Page.evaluate(page, "() => Date.now()") + + # Time should have advanced (or at least not be frozen) + # Note: This test might be flaky if execution is very fast + assert time2 >= time1 + + BrowserContext.close(context) + end + end + + describe "Clock.run_for/2" do + test "runs clock for specified duration", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + page = BrowserContext.new_page(context) + + Clock.install(context, %{time: 0}) + # 10 seconds + Clock.run_for(context, 10_000) + + time = Page.evaluate(page, "() => Date.now()") + assert time == 10_000 + + BrowserContext.close(context) + end + + test "runs clock for string duration", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + page = BrowserContext.new_page(context) + + Clock.install(context, %{time: 0}) + # 1 hour + Clock.run_for(context, "01:00:00") + + time = Page.evaluate(page, "() => Date.now()") + assert time == 3_600_000 + + BrowserContext.close(context) + end + end + + describe "Clock.pause_at/2 and Clock.resume/1" do + test "pauses and resumes the clock", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + page = BrowserContext.new_page(context) + + Clock.install(context) + Clock.pause_at(context, "2024-01-01T12:00:00Z") + + time_paused = Page.evaluate(page, "() => Date.now()") + + # Time should be frozen while paused + Process.sleep(50) + time_still_paused = Page.evaluate(page, "() => Date.now()") + assert time_paused == time_still_paused + + Clock.resume(context) + + # After resume, time should advance + Process.sleep(50) + time_after_resume = Page.evaluate(page, "() => Date.now()") + assert time_after_resume >= time_paused + + BrowserContext.close(context) + end + end +end diff --git a/test/api/coverage_test.exs b/test/api/coverage_test.exs new file mode 100644 index 00000000..2a618484 --- /dev/null +++ b/test/api/coverage_test.exs @@ -0,0 +1,36 @@ +defmodule Playwright.CoverageTest do + use Playwright.TestCase, async: true + alias Playwright.{Coverage, Page} + + describe "Coverage.start_js_coverage/2 and stop_js_coverage/1" do + test "collects JavaScript coverage", %{page: page} do + :ok = Coverage.start_js_coverage(page) + Page.goto(page, "data:text/html,") + entries = Coverage.stop_js_coverage(page) + assert is_list(entries) + end + + test "accepts options", %{page: page} do + :ok = Coverage.start_js_coverage(page, %{reset_on_navigation: false}) + Page.set_content(page, "") + entries = Coverage.stop_js_coverage(page) + assert is_list(entries) + end + end + + describe "Coverage.start_css_coverage/2 and stop_css_coverage/1" do + test "collects CSS coverage", %{page: page} do + :ok = Coverage.start_css_coverage(page) + Page.goto(page, "data:text/html,") + entries = Coverage.stop_css_coverage(page) + assert is_list(entries) + end + + test "accepts options", %{page: page} do + :ok = Coverage.start_css_coverage(page, %{reset_on_navigation: false}) + Page.set_content(page, "") + entries = Coverage.stop_css_coverage(page) + assert is_list(entries) + end + end +end diff --git a/test/api/dialog_test.exs b/test/api/dialog_test.exs new file mode 100644 index 00000000..498cf1e5 --- /dev/null +++ b/test/api/dialog_test.exs @@ -0,0 +1,161 @@ +defmodule Playwright.DialogTest do + use Playwright.TestCase, async: true + alias Playwright.{Dialog, Page} + alias Playwright.SDK.Channel.Event + + describe "Dialog.accept/1" do + test "accepts an alert dialog", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + # Spawn a task to handle dialog to avoid deadlock with Connection GenServer + Task.start(fn -> + send(test_pid, {:dialog_type, Dialog.type(dialog)}) + send(test_pid, {:dialog_message, Dialog.message(dialog)}) + Dialog.accept(dialog) + send(test_pid, :dialog_handled) + end) + end) + + Page.evaluate(page, "() => alert('Hello!')") + assert_receive({:dialog_type, "alert"}, 5000) + assert_receive({:dialog_message, "Hello!"}, 5000) + assert_receive(:dialog_handled, 5000) + end + + test "accepts a confirm dialog returning true", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:dialog_type, Dialog.type(dialog)}) + Dialog.accept(dialog) + send(test_pid, :dialog_handled) + end) + end) + + task = + Task.async(fn -> + Page.evaluate(page, "() => confirm('Accept?')") + end) + + assert_receive({:dialog_type, "confirm"}, 5000) + assert_receive(:dialog_handled, 5000) + result = Task.await(task) + assert result == true + end + end + + describe "Dialog.dismiss/1" do + test "dismisses a confirm dialog returning false", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + Dialog.dismiss(dialog) + send(test_pid, :dialog_handled) + end) + end) + + task = + Task.async(fn -> + Page.evaluate(page, "() => confirm('Accept?')") + end) + + assert_receive(:dialog_handled, 5000) + result = Task.await(task) + assert result == false + end + end + + describe "Dialog.accept/2 with prompt" do + test "accepts a prompt with text", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:dialog_type, Dialog.type(dialog)}) + send(test_pid, {:default_value, Dialog.default_value(dialog)}) + Dialog.accept(dialog, "my input") + send(test_pid, :dialog_handled) + end) + end) + + task = + Task.async(fn -> + Page.evaluate(page, "() => prompt('Enter:', 'default')") + end) + + assert_receive({:dialog_type, "prompt"}, 5000) + assert_receive({:default_value, "default"}, 5000) + assert_receive(:dialog_handled, 5000) + result = Task.await(task) + assert result == "my input" + end + + test "accepts a prompt without text uses empty string", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + Dialog.accept(dialog) + send(test_pid, :dialog_handled) + end) + end) + + task = + Task.async(fn -> + Page.evaluate(page, "() => prompt('Enter:', 'default value')") + end) + + assert_receive(:dialog_handled, 5000) + result = Task.await(task) + # When accepting without text, Playwright uses empty string, not default + assert result == "" + end + end + + describe "Dialog properties" do + test "message/1 returns dialog message", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:message, Dialog.message(dialog)}) + Dialog.accept(dialog) + end) + end) + + Page.evaluate(page, "() => alert('Test message')") + assert_receive({:message, "Test message"}, 5000) + end + + test "type/1 returns dialog type", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:type, Dialog.type(dialog)}) + Dialog.accept(dialog) + end) + end) + + Page.evaluate(page, "() => alert('Test')") + assert_receive({:type, "alert"}, 5000) + end + + test "default_value/1 returns prompt default", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:default_value, Dialog.default_value(dialog)}) + Dialog.accept(dialog) + end) + end) + + Page.evaluate(page, "() => prompt('Enter:', 'default value')") + assert_receive({:default_value, "default value"}, 5000) + end + end +end diff --git a/test/api/download_test.exs b/test/api/download_test.exs new file mode 100644 index 00000000..f60af524 --- /dev/null +++ b/test/api/download_test.exs @@ -0,0 +1,107 @@ +defmodule Playwright.DownloadTest do + use Playwright.TestCase, async: true + alias Playwright.{Download, Page} + alias Playwright.SDK.Channel.Event + + describe "Download" do + test "receives download event with artifact", %{assets: assets, browser: browser} do + test_pid = self() + + # Create a new context with accept_downloads enabled + context = Playwright.Browser.new_context(browser, %{accept_downloads: "accept"}) + page = Playwright.BrowserContext.new_page(context) + + Page.on(page, :download, fn %Event{} = event -> + Task.start(fn -> + download = Download.from_event(event) + send(test_pid, {:download, download}) + end) + end) + + Page.goto(page, assets.prefix <> "/download-blob.html") + Page.click(page, "a") + + assert_receive({:download, download}, 10_000) + assert %Download{} = download + assert Download.suggested_filename(download) == "example.txt" + + Playwright.BrowserContext.close(context) + end + + test "save_as saves download to file", %{assets: assets, browser: browser} do + test_pid = self() + save_path = Path.join(System.tmp_dir!(), "download_test_#{:rand.uniform(100_000)}.txt") + + context = Playwright.Browser.new_context(browser, %{accept_downloads: "accept"}) + page = Playwright.BrowserContext.new_page(context) + + Page.on(page, :download, fn %Event{} = event -> + Task.start(fn -> + download = Download.from_event(event) + result = Download.save_as(download, save_path) + send(test_pid, {:save_result, result}) + end) + end) + + Page.goto(page, assets.prefix <> "/download-blob.html") + Page.click(page, "a") + + assert_receive({:save_result, :ok}, 10_000) + assert File.exists?(save_path) + assert File.read!(save_path) == "Hello world" + + File.rm!(save_path) + Playwright.BrowserContext.close(context) + end + + test "path returns download path", %{assets: assets, browser: browser} do + test_pid = self() + + context = Playwright.Browser.new_context(browser, %{accept_downloads: "accept"}) + page = Playwright.BrowserContext.new_page(context) + + Page.on(page, :download, fn %Event{} = event -> + Task.start(fn -> + download = Download.from_event(event) + path = Download.path(download) + send(test_pid, {:path, path}) + end) + end) + + Page.goto(page, assets.prefix <> "/download-blob.html") + Page.click(page, "a") + + assert_receive({:path, path}, 10_000) + assert is_binary(path) + assert File.exists?(path) + assert File.read!(path) == "Hello world" + + Playwright.BrowserContext.close(context) + end + + test "url returns download URL", %{assets: assets, browser: browser} do + test_pid = self() + + context = Playwright.Browser.new_context(browser, %{accept_downloads: "accept"}) + page = Playwright.BrowserContext.new_page(context) + + Page.on(page, :download, fn %Event{} = event -> + Task.start(fn -> + download = Download.from_event(event) + url = Download.url(download) + send(test_pid, {:url, url}) + end) + end) + + Page.goto(page, assets.prefix <> "/download-blob.html") + Page.click(page, "a") + + assert_receive({:url, url}, 10_000) + assert is_binary(url) + # Blob URLs start with blob: + assert String.starts_with?(url, "blob:") + + Playwright.BrowserContext.close(context) + end + end +end diff --git a/test/api/extra_http_headers_test.exs b/test/api/extra_http_headers_test.exs new file mode 100644 index 00000000..efc7a0ff --- /dev/null +++ b/test/api/extra_http_headers_test.exs @@ -0,0 +1,60 @@ +defmodule Playwright.ExtraHTTPHeadersTest do + use Playwright.TestCase, async: true + alias Playwright.{BrowserContext, Page} + + describe "Page.set_extra_http_headers/2" do + test "adds headers to requests", %{assets: assets, page: page} do + Page.set_extra_http_headers(page, %{"X-Custom-Header" => "test-value"}) + + response = Page.goto(page, assets.empty) + assert response + end + + test "accepts multiple headers", %{assets: assets, page: page} do + Page.set_extra_http_headers(page, %{ + "X-Header-One" => "value1", + "X-Header-Two" => "value2", + "Authorization" => "Bearer token123" + }) + + response = Page.goto(page, assets.empty) + assert response + end + + test "converts atom keys to strings", %{assets: assets, page: page} do + Page.set_extra_http_headers(page, %{authorization: "Bearer token"}) + + response = Page.goto(page, assets.empty) + assert response + end + end + + describe "BrowserContext.set_extra_http_headers/2" do + test "adds headers to all context requests", %{browser: browser, assets: assets} do + context = Playwright.Browser.new_context(browser) + BrowserContext.set_extra_http_headers(context, %{"Authorization" => "Bearer token123"}) + + page = BrowserContext.new_page(context) + response = Page.goto(page, assets.empty) + assert response + + BrowserContext.close(context) + end + + test "headers apply to all pages in context", %{browser: browser, assets: assets} do + context = Playwright.Browser.new_context(browser) + BrowserContext.set_extra_http_headers(context, %{"X-Context-Header" => "shared"}) + + page1 = BrowserContext.new_page(context) + page2 = BrowserContext.new_page(context) + + response1 = Page.goto(page1, assets.empty) + response2 = Page.goto(page2, assets.empty) + + assert response1 + assert response2 + + BrowserContext.close(context) + end + end +end diff --git a/test/api/file_chooser_test.exs b/test/api/file_chooser_test.exs new file mode 100644 index 00000000..600ec4e6 --- /dev/null +++ b/test/api/file_chooser_test.exs @@ -0,0 +1,168 @@ +defmodule Playwright.FileChooserTest do + use Playwright.TestCase, async: true + alias Playwright.{FileChooser, Page} + alias Playwright.SDK.Channel.Event + + describe "FileChooser" do + test "set_files with single file", %{page: page} do + Page.set_content(page, "") + + path = Path.join(System.tmp_dir!(), "test-upload-#{:rand.uniform(100_000)}.txt") + File.write!(path, "test content") + + test_pid = self() + + try do + Page.on(page, :file_chooser, fn %Event{} = event -> + Task.start(fn -> + file_chooser = FileChooser.from_event(event) + FileChooser.set_files(file_chooser, path) + send(test_pid, :files_set) + end) + end) + + Page.click(page, "#upload") + + assert_receive :files_set, 5000 + + # Verify file was set + value = Page.evaluate(page, "document.querySelector('#upload').files[0]?.name") + assert value == Path.basename(path) + after + File.rm(path) + end + end + + test "set_files with multiple files", %{page: page} do + Page.set_content(page, "") + + path1 = Path.join(System.tmp_dir!(), "test1-#{:rand.uniform(100_000)}.txt") + path2 = Path.join(System.tmp_dir!(), "test2-#{:rand.uniform(100_000)}.txt") + File.write!(path1, "content 1") + File.write!(path2, "content 2") + + test_pid = self() + + try do + Page.on(page, :file_chooser, fn %Event{} = event -> + Task.start(fn -> + file_chooser = FileChooser.from_event(event) + FileChooser.set_files(file_chooser, [path1, path2]) + send(test_pid, :files_set) + end) + end) + + Page.click(page, "#upload") + + assert_receive :files_set, 5000 + + count = Page.evaluate(page, "document.querySelector('#upload').files.length") + assert count == 2 + after + File.rm(path1) + File.rm(path2) + end + end + + test "is_multiple returns false for single file input", %{page: page} do + Page.set_content(page, "") + + test_pid = self() + + Page.on(page, :file_chooser, fn %Event{} = event -> + Task.start(fn -> + file_chooser = FileChooser.from_event(event) + send(test_pid, {:is_multiple, FileChooser.is_multiple(file_chooser)}) + end) + end) + + Page.click(page, "#upload") + + assert_receive {:is_multiple, is_multiple}, 5000 + assert is_multiple == false + end + + test "is_multiple returns true for multiple file input", %{page: page} do + Page.set_content(page, "") + + test_pid = self() + + Page.on(page, :file_chooser, fn %Event{} = event -> + Task.start(fn -> + file_chooser = FileChooser.from_event(event) + send(test_pid, {:is_multiple, FileChooser.is_multiple(file_chooser)}) + end) + end) + + Page.click(page, "#upload") + + assert_receive {:is_multiple, is_multiple}, 5000 + assert is_multiple == true + end + + test "element returns the input element", %{page: page} do + Page.set_content(page, "") + + test_pid = self() + + Page.on(page, :file_chooser, fn %Event{} = event -> + Task.start(fn -> + file_chooser = FileChooser.from_event(event) + element = FileChooser.element(file_chooser) + send(test_pid, {:element, element}) + end) + end) + + Page.click(page, "#upload") + + assert_receive {:element, element}, 5000 + assert %Playwright.ElementHandle{} = element + end + + test "page returns the page", %{page: page} do + Page.set_content(page, "") + + test_pid = self() + + Page.on(page, :file_chooser, fn %Event{} = event -> + Task.start(fn -> + file_chooser = FileChooser.from_event(event) + fc_page = FileChooser.page(file_chooser) + send(test_pid, {:page, fc_page}) + end) + end) + + Page.click(page, "#upload") + + assert_receive {:page, fc_page}, 5000 + assert fc_page.guid == page.guid + end + + test "set_files with file payload", %{page: page} do + Page.set_content(page, "") + + test_pid = self() + + Page.on(page, :file_chooser, fn %Event{} = event -> + Task.start(fn -> + file_chooser = FileChooser.from_event(event) + + FileChooser.set_files(file_chooser, %{ + name: "test-payload.txt", + mimeType: "text/plain", + buffer: Base.encode64("Hello from payload") + }) + + send(test_pid, :files_set) + end) + end) + + Page.click(page, "#upload") + + assert_receive :files_set, 5000 + + value = Page.evaluate(page, "document.querySelector('#upload').files[0]?.name") + assert value == "test-payload.txt" + end + end +end diff --git a/test/api/frame_element_test.exs b/test/api/frame_element_test.exs new file mode 100644 index 00000000..0f48bd68 --- /dev/null +++ b/test/api/frame_element_test.exs @@ -0,0 +1,38 @@ +defmodule Playwright.FrameElementTest do + use Playwright.TestCase, async: true + alias Playwright.{ElementHandle, Frame, Page} + + describe "Frame.frame_element/1" do + test "returns the iframe element for a child frame", %{page: page, assets: assets} do + Page.set_content(page, """ + + """) + + Page.wait_for_load_state(page, "load") + + frames = Page.frames(page) + child_frame = Enum.find(frames, fn f -> f.url =~ "empty.html" end) + + assert child_frame != nil + + element = Frame.frame_element(child_frame) + assert %ElementHandle{} = element + + # Verify it's the iframe element + tag_name = ElementHandle.evaluate(element, "e => e.tagName.toLowerCase()") + assert tag_name == "iframe" + + # Verify it has our id + id = ElementHandle.get_attribute(element, "id") + assert id == "my-iframe" + end + + test "returns error for main frame", %{page: page} do + main_frame = Page.main_frame(page) + + result = Frame.frame_element(main_frame) + + assert {:error, _} = result + end + end +end diff --git a/test/api/frame_hierarchy_test.exs b/test/api/frame_hierarchy_test.exs new file mode 100644 index 00000000..dd607c3c --- /dev/null +++ b/test/api/frame_hierarchy_test.exs @@ -0,0 +1,89 @@ +defmodule Playwright.FrameHierarchyTest do + use Playwright.TestCase, async: true + alias Playwright.{Frame, Page} + + describe "Frame.page/1" do + test "returns the page for main frame", %{page: page} do + frame = Page.main_frame(page) + assert Frame.page(frame).guid == page.guid + end + + test "returns the page for iframe", %{page: page, assets: assets} do + Page.goto(page, assets.prefix <> "/frames/one-frame.html") + frames = Page.frames(page) + child = Enum.find(frames, fn f -> f.url =~ "frame.html" end) + + assert Frame.page(child).guid == page.guid + end + end + + describe "Frame.parent_frame/1" do + test "returns nil for main frame", %{page: page} do + frame = Page.main_frame(page) + assert Frame.parent_frame(frame) == nil + end + + test "returns parent for iframe", %{page: page, assets: assets} do + Page.goto(page, assets.prefix <> "/frames/one-frame.html") + # Wait for page to fully load including iframe + Page.wait_for_load_state(page, "load") + + frames = Page.frames(page) + # Match specifically the iframe URL (ends with /frame.html, not /one-frame.html) + child = Enum.find(frames, fn f -> String.ends_with?(f.url, "/frame.html") end) + + assert child != nil + parent = Frame.parent_frame(child) + assert parent != nil + assert parent.guid == Page.main_frame(page).guid + end + end + + describe "Frame.child_frames/1" do + test "returns empty for page with no iframes", %{page: page} do + Page.set_content(page, "

No frames

") + frame = Page.main_frame(page) + assert Frame.child_frames(frame) == [] + end + + test "returns children for parent frame", %{page: page, assets: assets} do + Page.goto(page, assets.prefix <> "/frames/one-frame.html") + main = Page.main_frame(page) + children = Frame.child_frames(main) + + assert length(children) == 1 + assert hd(children).url =~ "frame.html" + end + + test "returns multiple children for nested frames page", %{page: page, assets: assets} do + Page.goto(page, assets.prefix <> "/frames/two-frames.html") + main = Page.main_frame(page) + children = Frame.child_frames(main) + + assert length(children) == 2 + end + end + + describe "Frame.name/1" do + test "returns empty string for unnamed frame", %{page: page} do + frame = Page.main_frame(page) + # Main frame typically has no name + assert Frame.name(frame) == "" || Frame.name(frame) == nil + end + end + + describe "Frame.is_detached/1" do + test "returns false for attached frame", %{page: page} do + frame = Page.main_frame(page) + assert Frame.is_detached(frame) == false + end + + test "returns false for attached iframe", %{page: page, assets: assets} do + Page.goto(page, assets.prefix <> "/frames/one-frame.html") + frames = Page.frames(page) + child = Enum.find(frames, fn f -> f.url =~ "frame.html" end) + + assert Frame.is_detached(child) == false + end + end +end diff --git a/test/api/frame_locator_test.exs b/test/api/frame_locator_test.exs new file mode 100644 index 00000000..ccc8ba39 --- /dev/null +++ b/test/api/frame_locator_test.exs @@ -0,0 +1,242 @@ +defmodule Playwright.FrameLocatorTest do + use Playwright.TestCase, async: true + alias Playwright.{Frame, Locator, Page} + alias Playwright.Page.FrameLocator + + describe "Page.frame_locator/2" do + test "returns a FrameLocator struct", %{page: page} do + Page.set_content(page, ~s||) + + result = Page.frame_locator(page, "#frame1") + + assert %FrameLocator{} = result + assert result.selector == "#frame1" + end + end + + describe "Frame.frame_locator/2" do + test "returns a FrameLocator struct", %{page: page} do + Page.set_content(page, ~s||) + frame = Page.main_frame(page) + + result = Frame.frame_locator(frame, "#frame1") + + assert %FrameLocator{} = result + assert result.selector == "#frame1" + end + end + + describe "FrameLocator.locator/2" do + test "locates elements inside an iframe", %{assets: assets, page: page} do + _frame = attach_frame(page, "frame1", assets.prefix <> "/input/button.html") + + button = + page + |> Page.frame_locator("#frame1") + |> FrameLocator.locator("button") + + assert %Locator{} = button + assert Locator.text_content(button) == "Click target" + end + + test "can click elements inside an iframe", %{assets: assets, page: page} do + Page.set_content(page, ~s|
spacer
|) + frame = attach_frame(page, "frame1", assets.prefix <> "/input/button.html") + + page + |> Page.frame_locator("#frame1") + |> FrameLocator.locator("button") + |> Locator.click() + + assert Frame.evaluate(frame, "window.result") == "Clicked" + end + end + + describe "FrameLocator.first/1, last/1, nth/2" do + test "first returns FrameLocator with nth=0 selector", %{page: page} do + Page.set_content(page, ~s| + + + |) + + fl = Page.frame_locator(page, "iframe.frame") + first_fl = FrameLocator.first(fl) + + assert %FrameLocator{} = first_fl + assert first_fl.selector == "iframe.frame >> nth=0" + end + + test "last returns FrameLocator with nth=-1 selector", %{page: page} do + Page.set_content(page, ~s| + + + |) + + fl = Page.frame_locator(page, "iframe.frame") + last_fl = FrameLocator.last(fl) + + assert %FrameLocator{} = last_fl + assert last_fl.selector == "iframe.frame >> nth=-1" + end + + test "nth returns FrameLocator with specified index", %{page: page} do + Page.set_content(page, ~s||) + + fl = Page.frame_locator(page, "iframe.frame") + nth_fl = FrameLocator.nth(fl, 2) + + assert %FrameLocator{} = nth_fl + assert nth_fl.selector == "iframe.frame >> nth=2" + end + end + + describe "FrameLocator.frame_locator/2 (nested)" do + test "creates nested frame selector", %{page: page} do + Page.set_content(page, ~s||) + + nested_fl = + page + |> Page.frame_locator("#outer") + |> FrameLocator.frame_locator("#inner") + + assert %FrameLocator{} = nested_fl + assert nested_fl.selector == "#outer >> internal:control=enter-frame >> #inner" + end + end + + describe "FrameLocator.owner/1" do + test "returns Locator pointing to the iframe element", %{page: page} do + Page.set_content(page, ~s||) + + owner_locator = + page + |> Page.frame_locator("#frame1") + |> FrameLocator.owner() + + assert %Locator{} = owner_locator + assert Locator.get_attribute(owner_locator, "title") == "My Frame" + end + end + + describe "FrameLocator.get_by_text/3" do + test "locates element by text inside iframe", %{page: page} do + Page.set_content(page, ~s||) + + Page.evaluate(page, """ + const iframe = document.querySelector('#frame1'); + iframe.contentDocument.body.innerHTML = '
Hello World
'; + """) + + locator = + page + |> Page.frame_locator("#frame1") + |> FrameLocator.get_by_text("Hello World") + + assert %Locator{} = locator + assert String.contains?(locator.selector, "internal:control=enter-frame") + assert String.contains?(locator.selector, "internal:text=") + end + end + + describe "FrameLocator.get_by_role/3" do + test "locates element by role inside iframe", %{assets: assets, page: page} do + Page.set_content(page, ~s|
spacer
|) + frame = attach_frame(page, "frame1", assets.prefix <> "/input/button.html") + + locator = + page + |> Page.frame_locator("#frame1") + |> FrameLocator.get_by_role("button") + + assert %Locator{} = locator + Locator.click(locator) + + assert Frame.evaluate(frame, "window.result") == "Clicked" + end + end + + describe "FrameLocator.get_by_test_id/2" do + test "locates element by test id inside iframe", %{page: page} do + Page.set_content(page, ~s||) + + Page.evaluate(page, """ + const iframe = document.querySelector('#frame1'); + iframe.contentDocument.body.innerHTML = ''; + """) + + locator = + page + |> Page.frame_locator("#frame1") + |> FrameLocator.get_by_test_id("submit-btn") + + assert %Locator{} = locator + assert String.contains?(locator.selector, "internal:control=enter-frame") + assert String.contains?(locator.selector, "internal:testid=") + end + end + + describe "FrameLocator.get_by_label/3" do + test "locates input by label inside iframe", %{page: page} do + Page.set_content(page, ~s||) + + Page.evaluate(page, """ + const iframe = document.querySelector('#frame1'); + iframe.contentDocument.body.innerHTML = ''; + """) + + locator = + page + |> Page.frame_locator("#frame1") + |> FrameLocator.get_by_label("Username") + + assert %Locator{} = locator + assert String.contains?(locator.selector, "internal:control=enter-frame") + assert String.contains?(locator.selector, "internal:label=") + end + end + + describe "FrameLocator.get_by_placeholder/3" do + test "returns Locator with correct selector", %{page: page} do + Page.set_content(page, ~s||) + + locator = + page + |> Page.frame_locator("#frame1") + |> FrameLocator.get_by_placeholder("Enter email") + + assert %Locator{} = locator + assert String.contains?(locator.selector, "internal:control=enter-frame") + assert String.contains?(locator.selector, "internal:attr=[placeholder=") + end + end + + describe "FrameLocator.get_by_alt_text/3" do + test "returns Locator with correct selector", %{page: page} do + Page.set_content(page, ~s||) + + locator = + page + |> Page.frame_locator("#frame1") + |> FrameLocator.get_by_alt_text("Logo image") + + assert %Locator{} = locator + assert String.contains?(locator.selector, "internal:control=enter-frame") + assert String.contains?(locator.selector, "internal:attr=[alt=") + end + end + + describe "FrameLocator.get_by_title/3" do + test "returns Locator with correct selector", %{page: page} do + Page.set_content(page, ~s||) + + locator = + page + |> Page.frame_locator("#frame1") + |> FrameLocator.get_by_title("Help tooltip") + + assert %Locator{} = locator + assert String.contains?(locator.selector, "internal:control=enter-frame") + assert String.contains?(locator.selector, "internal:attr=[title=") + end + end +end diff --git a/test/api/frame_test.exs b/test/api/frame_test.exs index 890c59a9..d36aea95 100644 --- a/test/api/frame_test.exs +++ b/test/api/frame_test.exs @@ -21,4 +21,20 @@ defmodule Playwright.FrameTest do "
first
" end end + + describe "Frame.highlight/2" do + test "highlights elements matching the selector", %{page: page} do + Page.set_content(page, ~s|
Hello
|) + frame = Page.main_frame(page) + assert :ok = Frame.highlight(frame, "#target") + end + end + + describe "Frame.page/1" do + test "returns the page containing the frame", %{page: page} do + frame = Page.main_frame(page) + result = Frame.page(frame) + assert result.guid == page.guid + end + end end diff --git a/test/api/get_by_test.exs b/test/api/get_by_test.exs new file mode 100644 index 00000000..7246eb50 --- /dev/null +++ b/test/api/get_by_test.exs @@ -0,0 +1,305 @@ +defmodule Playwright.GetByTest do + use Playwright.TestCase, async: true + alias Playwright.{Locator, Page} + + describe "get_by_test_id/2" do + test "locates element by data-testid", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_test_id(page, "submit-btn") + assert Locator.text_content(locator) == "Submit" + end + + test "locates element with special characters in testid", %{page: page} do + Page.set_content(page, ~s|
Profile
|) + locator = Page.get_by_test_id(page, "user-profile-123") + assert Locator.text_content(locator) == "Profile" + end + end + + describe "get_by_label/3" do + test "locates input by label text (implicit association)", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_label(page, "Email") + assert Locator.count(locator) == 1 + end + + test "locates input by label text (explicit for association)", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_label(page, "Email") + assert Locator.count(locator) == 1 + end + + test "with exact option matches only exact text", %{page: page} do + Page.set_content( + page, + ~s|| + ) + + locator = Page.get_by_label(page, "Email", %{exact: true}) + assert Locator.count(locator) == 1 + assert Locator.get_attribute(locator, "id") == "short" + end + + test "without exact option matches partial text", %{page: page} do + Page.set_content( + page, + ~s|| + ) + + locator = Page.get_by_label(page, "Email") + assert Locator.count(locator) == 2 + end + end + + describe "get_by_role/3" do + test "locates element by role", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_role(page, "button") + assert Locator.text_content(locator) == "Click me" + end + + test "filters by name option", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_role(page, "button", %{name: "OK"}) + assert Locator.count(locator) == 1 + assert Locator.text_content(locator) == "OK" + end + + test "filters by disabled state", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_role(page, "button", %{disabled: true}) + assert Locator.text_content(locator) == "Cancel" + end + + test "locates headings by level", %{page: page} do + Page.set_content(page, ~s|

Title

Subtitle

Section

|) + locator = Page.get_by_role(page, "heading", %{level: 2}) + assert Locator.text_content(locator) == "Subtitle" + end + + test "locates link by name", %{page: page} do + Page.set_content(page, ~s|HomeAbout|) + locator = Page.get_by_role(page, "link", %{name: "About"}) + assert Locator.get_attribute(locator, "href") == "/about" + end + + test "filters by checked state", %{page: page} do + Page.set_content( + page, + ~s|| + ) + + locator = Page.get_by_role(page, "checkbox", %{checked: true}) + assert Locator.get_attribute(locator, "id") == "b" + end + end + + describe "chaining getBy methods" do + test "chains getBy methods on Page then Locator", %{page: page} do + Page.set_content(page, ~s| +
+ + +
+
+ +
+ |) + + locator = + page + |> Page.get_by_test_id("form") + |> Locator.get_by_role("button", %{name: "Submit"}) + + assert Locator.text_content(locator) == "Submit" + end + + test "chains multiple getBy methods on Locator", %{page: page} do + Page.set_content(page, ~s| +
+
+ +
+
+ +
+
+ |) + + locator = + page + |> Page.get_by_test_id("users") + |> Locator.get_by_test_id("user-1") + |> Locator.get_by_role("button") + + assert Locator.count(locator) == 1 + end + end + + describe "Frame.get_by_* methods" do + test "get_by_test_id works on frame", %{page: page} do + Page.set_content(page, ~s|Frame content|) + frame = Page.main_frame(page) + locator = Playwright.Frame.get_by_test_id(frame, "main") + assert Locator.text_content(locator) == "Frame content" + end + + test "get_by_label works on frame", %{page: page} do + Page.set_content(page, ~s||) + frame = Page.main_frame(page) + locator = Playwright.Frame.get_by_label(frame, "Username") + assert Locator.count(locator) == 1 + end + + test "get_by_role works on frame", %{page: page} do + Page.set_content(page, ~s||) + frame = Page.main_frame(page) + locator = Playwright.Frame.get_by_role(frame, "navigation") + assert Locator.count(locator) == 1 + end + end + + describe "get_by_placeholder/3" do + test "locates input by placeholder text", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_placeholder(page, "Enter your email") + assert Locator.count(locator) == 1 + end + + test "locates input with partial placeholder match", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_placeholder(page, "email") + assert Locator.count(locator) == 1 + end + + test "with exact option matches only exact text", %{page: page} do + Page.set_content( + page, + ~s|| + ) + + locator = Page.get_by_placeholder(page, "email", %{exact: true}) + assert Locator.count(locator) == 1 + assert Locator.get_attribute(locator, "id") == "short" + end + + test "works on Frame", %{page: page} do + Page.set_content(page, ~s||) + frame = Page.main_frame(page) + locator = Playwright.Frame.get_by_placeholder(frame, "Search") + assert Locator.count(locator) == 1 + end + + test "works on Locator", %{page: page} do + Page.set_content(page, ~s| +
+ + +
+ |) + + locator = + page + |> Page.get_by_test_id("login") + |> Locator.get_by_placeholder("Password") + + assert Locator.count(locator) == 1 + end + end + + describe "get_by_alt_text/3" do + test "locates image by alt text", %{page: page} do + Page.set_content(page, ~s|Company Logo|) + locator = Page.get_by_alt_text(page, "Company Logo") + assert Locator.count(locator) == 1 + end + + test "locates image with partial alt text match", %{page: page} do + Page.set_content(page, ~s|Acme Corporation Logo|) + locator = Page.get_by_alt_text(page, "Acme") + assert Locator.count(locator) == 1 + end + + test "with exact option matches only exact text", %{page: page} do + Page.set_content( + page, + ~s|Logo ImageLogo| + ) + + locator = Page.get_by_alt_text(page, "Logo", %{exact: true}) + assert Locator.count(locator) == 1 + assert Locator.get_attribute(locator, "id") == "short" + end + + test "works on Frame", %{page: page} do + Page.set_content(page, ~s|Profile Picture|) + frame = Page.main_frame(page) + locator = Playwright.Frame.get_by_alt_text(frame, "Profile") + assert Locator.count(locator) == 1 + end + + test "works on Locator", %{page: page} do + Page.set_content(page, ~s| +
+ Photo 1 + Photo 2 +
+ |) + + locator = + page + |> Page.get_by_test_id("gallery") + |> Locator.get_by_alt_text("Photo 1") + + assert Locator.count(locator) == 1 + end + end + + describe "get_by_title/3" do + test "locates element by title attribute", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_title(page, "Submit form") + assert Locator.text_content(locator) == "Submit" + end + + test "locates element with partial title match", %{page: page} do + Page.set_content(page, ~s|Info|) + locator = Page.get_by_title(page, "learn more") + assert Locator.count(locator) == 1 + end + + test "with exact option matches only exact text", %{page: page} do + Page.set_content( + page, + ~s|AB| + ) + + locator = Page.get_by_title(page, "Help", %{exact: true}) + assert Locator.count(locator) == 1 + assert Locator.get_attribute(locator, "id") == "short" + end + + test "works on Frame", %{page: page} do + Page.set_content(page, ~s|HTML|) + frame = Page.main_frame(page) + locator = Playwright.Frame.get_by_title(frame, "HyperText") + assert Locator.count(locator) == 1 + end + + test "works on Locator", %{page: page} do + Page.set_content(page, ~s| +
+ + +
+ |) + + locator = + page + |> Page.get_by_test_id("toolbar") + |> Locator.get_by_title("Bold") + + assert Locator.text_content(locator) == "B" + end + end +end diff --git a/test/api/locator_filter_test.exs b/test/api/locator_filter_test.exs new file mode 100644 index 00000000..351d960b --- /dev/null +++ b/test/api/locator_filter_test.exs @@ -0,0 +1,133 @@ +defmodule Playwright.LocatorFilterTest do + use Playwright.TestCase, async: true + alias Playwright.{Locator, Page} + + describe "Locator.filter/2" do + test "filters by has_text", %{page: page} do + Page.set_content(page, """ +
Hello
+
World
+ """) + + locator = Page.locator(page, "div") |> Locator.filter(has_text: "Hello") + assert Locator.count(locator) == 1 + assert Locator.text_content(locator) == "Hello" + end + + test "filters by has_not_text", %{page: page} do + Page.set_content(page, """ +
Hello
+
World
+ """) + + locator = Page.locator(page, "div") |> Locator.filter(has_not_text: "Hello") + assert Locator.count(locator) == 1 + assert Locator.text_content(locator) == "World" + end + + test "filters by has (nested locator)", %{page: page} do + Page.set_content(page, """ +
+
Text
+ """) + + button = Page.locator(page, "button") + locator = Page.locator(page, "div") |> Locator.filter(has: button) + assert Locator.count(locator) == 1 + end + + test "filters by has_not (nested locator)", %{page: page} do + Page.set_content(page, """ +
+
Text
+ """) + + button = Page.locator(page, "button") + locator = Page.locator(page, "div") |> Locator.filter(has_not: button) + assert Locator.count(locator) == 1 + end + + test "filters by visibility", %{page: page} do + Page.set_content(page, """ +
Visible
+
Hidden
+ """) + + locator = Page.locator(page, "div") |> Locator.filter(visible: true) + assert Locator.count(locator) == 1 + assert Locator.text_content(locator) == "Visible" + end + + test "filters by visible: false", %{page: page} do + Page.set_content(page, """ +
Visible
+
Hidden
+ """) + + locator = Page.locator(page, "div") |> Locator.filter(visible: false) + assert Locator.count(locator) == 1 + end + + test "combines multiple filters", %{page: page} do + Page.set_content(page, """ +
+
+
Other
+ """) + + button = Page.locator(page, "button") + + locator = + Page.locator(page, "div") + |> Locator.filter(has: button, has_text: "Submit") + + assert Locator.count(locator) == 1 + end + + test "filters with regex", %{page: page} do + Page.set_content(page, """ +
Hello World
+
Goodbye World
+ """) + + locator = Page.locator(page, "div") |> Locator.filter(has_text: ~r/^Hello/) + assert Locator.count(locator) == 1 + end + + test "filters with case-insensitive regex", %{page: page} do + Page.set_content(page, """ +
Hello World
+
Goodbye World
+ """) + + locator = Page.locator(page, "div") |> Locator.filter(has_text: ~r/^hello/i) + assert Locator.count(locator) == 1 + end + + test "chains multiple filter calls", %{page: page} do + Page.set_content(page, """ +
Apple$1
+
Banana$2
+
Cherry
+ """) + + price_span = Page.locator(page, ".price") + + locator = + Page.locator(page, ".item") + |> Locator.filter(has: price_span) + |> Locator.filter(has_text: "Apple") + + assert Locator.count(locator) == 1 + end + + test "raises for unknown filter option", %{page: page} do + Page.set_content(page, "
Test
") + locator = Page.locator(page, "div") + + assert_raise ArgumentError, ~r/Unknown filter option/, fn -> + Locator.filter(locator, unknown_option: "value") + end + end + end +end diff --git a/test/api/locator_handler_test.exs b/test/api/locator_handler_test.exs new file mode 100644 index 00000000..79977594 --- /dev/null +++ b/test/api/locator_handler_test.exs @@ -0,0 +1,161 @@ +defmodule Playwright.LocatorHandlerTest do + use Playwright.TestCase, async: false + alias Playwright.{Locator, Page} + + describe "add_locator_handler/4" do + test "auto-dismisses overlay when it appears", %{page: page} do + Page.set_content(page, """ + + + + + """) + + dialog = Page.locator(page, "#dialog") + close_btn = Page.locator(page, "#close") + + # Register handler to auto-close dialog + :ok = + Page.add_locator_handler(page, dialog, fn _loc -> + Locator.click(close_btn) + end) + + # Click show button which displays the dialog + Page.click(page, "#show") + + # Now click target - should work because handler will dismiss dialog + Page.click(page, "#target") + + # Dialog should be hidden + assert Locator.is_hidden(dialog) + end + + test "handler receives the locator as argument", %{page: page} do + Page.set_content(page, """ + + + + """) + + banner = Page.locator(page, "#banner") + + :ok = + Page.add_locator_handler(page, banner, fn loc -> + # Get text from the locator passed to handler + text = Locator.text_content(loc) + Page.evaluate(page, "text => window.receivedText = text", text) + Page.evaluate(page, "document.getElementById('banner').style.display = 'none'") + end) + + Page.click(page, "#action") + + # Verify handler received the locator and could use it + assert Page.evaluate(page, "window.receivedText") == "Cookie Banner" + end + + test "times option limits executions", %{page: page} do + Page.set_content(page, """ +
0
+
Overlay
+ + + """) + + overlay = Page.locator(page, "#overlay") + + # Handler should only run twice + :ok = + Page.add_locator_handler( + page, + overlay, + fn _loc -> + Page.evaluate(page, "window.incrementCounter()") + Page.evaluate(page, "document.getElementById('overlay').style.display = 'none'") + end, + %{times: 2} + ) + + # First action triggers handler + Page.click(page, "#action") + assert Page.text_content(page, "#counter") == "1" + + # Show overlay again + Page.evaluate(page, "document.getElementById('overlay').style.display = 'block'") + + # Second action triggers handler (times: 2) + Page.click(page, "#action") + assert Page.text_content(page, "#counter") == "2" + + # Show overlay again + Page.evaluate(page, "document.getElementById('overlay').style.display = 'block'") + + # Third action - handler should be exhausted + # Hide overlay manually so click can proceed + Page.evaluate(page, "document.getElementById('overlay').style.display = 'none'") + Page.click(page, "#action") + + # Counter should still be 2 (handler didn't run third time) + assert Page.text_content(page, "#counter") == "2" + end + end + + describe "remove_locator_handler/2" do + test "stops handling after removal", %{page: page} do + Page.set_content(page, """ +
0
+
Overlay
+ + + """) + + overlay = Page.locator(page, "#overlay") + + # Add handler + :ok = + Page.add_locator_handler(page, overlay, fn _loc -> + Page.evaluate(page, "window.incrementCounter()") + Page.evaluate(page, "document.getElementById('overlay').style.display = 'none'") + end) + + # First action triggers handler + Page.click(page, "#action") + assert Page.text_content(page, "#counter") == "1" + + # Remove handler + :ok = Page.remove_locator_handler(page, overlay) + + # Show overlay again and manually hide it + Page.evaluate(page, "document.getElementById('overlay').style.display = 'block'") + Page.evaluate(page, "document.getElementById('overlay').style.display = 'none'") + + # Action should work but handler shouldn't have been called + Page.click(page, "#action") + + # Counter should still be 1 (handler was removed) + assert Page.text_content(page, "#counter") == "1" + end + end +end diff --git a/test/api/locator_test.exs b/test/api/locator_test.exs index 34a0dc7d..db97746f 100644 --- a/test/api/locator_test.exs +++ b/test/api/locator_test.exs @@ -41,6 +41,61 @@ defmodule Playwright.LocatorTest do end end + describe "Locator.aria_snapshot/2" do + test "returns aria snapshot of element", %{page: page} do + Page.set_content(page, "") + locator = Page.locator(page, "button") + snapshot = Locator.aria_snapshot(locator) + assert is_binary(snapshot) + end + end + + # TODO: Locator.describe/2 requires Playwright > 1.49.1 + + describe "Locator.and_/2" do + test "matches elements that satisfy both locators", %{page: page} do + Page.set_content(page, ~s| +
hello
+
world
+
both
+ |) + + locator = + page + |> Page.locator(".foo") + |> Locator.and_(Page.locator(page, ".bar")) + + assert Locator.count(locator) == 1 + assert Locator.text_content(locator) == "both" + end + + test "returns empty when no elements match both", %{page: page} do + Page.set_content(page, ~s| +
hello
+ world + |) + + locator = + page + |> Page.locator(".foo") + |> Locator.and_(Page.locator(page, ".bar")) + + assert Locator.count(locator) == 0 + end + + test "raises when locators belong to different frames", %{page: page, browser: browser} do + {:ok, other_page} = Playwright.Browser.new_page(browser) + + assert_raise ArgumentError, "Locators must belong to the same frame", fn -> + page + |> Page.locator("div") + |> Locator.and_(Page.locator(other_page, "span")) + end + + Page.close(other_page) + end + end + describe "Locator.blur/2 AND Locator.focus/2" do test "deactivates/activates an element", %{assets: assets, page: page} do button = Page.locator(page, "button") @@ -135,7 +190,21 @@ defmodule Playwright.LocatorTest do end end - # test_locator_content_frame_should_work + describe "Locator.content_frame/1" do + test "returns a FrameLocator for the iframe element", %{page: page} do + alias Playwright.Page.FrameLocator + + Page.set_content(page, ~s||) + + frame_locator = + page + |> Page.locator("#my-frame") + |> Locator.content_frame() + + assert %FrameLocator{} = frame_locator + assert frame_locator.selector == "#my-frame" + end + end # describe "Locator.count/1" do # test "returns the number of elements matching the given selector" do @@ -464,6 +533,26 @@ defmodule Playwright.LocatorTest do end end + describe "Locator.frame_locator/2" do + test "returns a FrameLocator scoped to the locator", %{page: page} do + alias Playwright.Page.FrameLocator + + Page.set_content(page, ~s| +
+ +
+ |) + + frame_locator = + page + |> Page.locator("#container") + |> Locator.frame_locator("#my-frame") + + assert %FrameLocator{} = frame_locator + assert frame_locator.selector == "#container >> #my-frame" + end + end + describe "Locator.get_by_text/3" do test "returns a locator that contains the given text", %{page: page} do Page.set_content(page, "
first
second
\nthird
") @@ -673,17 +762,20 @@ defmodule Playwright.LocatorTest do end test "raises an error when the given locators don't share a frame", %{page: page, browser: browser} do - other_page = Playwright.Browser.new_page(browser) + {:ok, other_page} = Playwright.Browser.new_page(browser) on_exit(:ok, fn -> Playwright.Page.close(other_page) end) + page |> Page.set_content("
") div_locator = Page.locator(page, "div") + other_page |> Page.set_content("") + span_locator = Page.locator(other_page, "span") assert_raise ArgumentError, "Locators must belong to the same frame", fn -> @@ -702,6 +794,26 @@ defmodule Playwright.LocatorTest do end end + describe "Locator.press_sequentially/3" do + test "types text character by character", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.locator(page, "input") + + Locator.press_sequentially(locator, "Hello") + + assert Locator.input_value(locator) == "Hello" + end + + test "respects delay option", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.locator(page, "input") + + Locator.press_sequentially(locator, "Hi", %{delay: 10}) + + assert Locator.input_value(locator) == "Hi" + end + end + describe "Locator.screenshot/2" do test "captures an image of the element", %{assets: assets, page: page} do fixture = File.read!("test/support/fixtures/screenshot-element-bounding-box-chromium.png") @@ -915,4 +1027,20 @@ defmodule Playwright.LocatorTest do assert [:ok, %Locator{}] = Task.await_many([setup, check]) end end + + describe "Locator.highlight/1" do + test "highlights elements matching the locator", %{page: page} do + Page.set_content(page, ~s|
Hello
|) + locator = Locator.new(page, "#target") + assert :ok = Locator.highlight(locator) + end + end + + describe "Locator.page/1" do + test "returns the page containing the locator", %{page: page} do + locator = Locator.new(page, "div") + result = Locator.page(locator) + assert result.guid == page.guid + end + end end diff --git a/test/api/mouse_test.exs b/test/api/mouse_test.exs new file mode 100644 index 00000000..0035b6ba --- /dev/null +++ b/test/api/mouse_test.exs @@ -0,0 +1,95 @@ +defmodule Playwright.MouseTest do + use Playwright.TestCase, async: true + alias Playwright.{Mouse, Page} + + describe "Mouse.click/4" do + test "clicks at coordinates", %{page: page} do + Page.set_content(page, """ + + """) + + Mouse.click(page, 50, 50) + + result = Page.evaluate(page, "() => window.clicked") + assert result == true + end + + test "clicks with right button", %{page: page} do + Page.set_content(page, """ +
+ """) + + Mouse.click(page, 50, 50, button: "right") + + result = Page.evaluate(page, "() => window.rightClicked") + assert result == true + end + end + + describe "Mouse.dblclick/4" do + test "double-clicks at coordinates", %{page: page} do + Page.set_content(page, """ +
+ """) + + Mouse.dblclick(page, 50, 50) + + result = Page.evaluate(page, "() => window.dblClicked") + assert result == true + end + end + + describe "Mouse.move/4" do + test "moves mouse to coordinates", %{page: page} do + Page.set_content(page, """ +
+ """) + + Mouse.move(page, 100, 100) + + result = Page.evaluate(page, "() => window.lastMove") + x = Map.get(result, :x) || Map.get(result, "x") + y = Map.get(result, :y) || Map.get(result, "y") + assert x == 100 + assert y == 100 + end + end + + describe "Mouse.down/2 and Mouse.up/2" do + test "dispatches mousedown and mouseup events", %{page: page} do + Page.set_content(page, """ +
+ """) + + Mouse.move(page, 50, 50) + Mouse.down(page) + + assert Page.evaluate(page, "() => window.mouseDown") == true + + Mouse.up(page) + + assert Page.evaluate(page, "() => window.mouseUp") == true + end + end + + describe "Mouse.wheel/3" do + test "scrolls the page", %{page: page} do + Page.set_content(page, """ +
+ """) + + initial_scroll = Page.evaluate(page, "() => window.scrollY") + Mouse.wheel(page, 0, 100) + # Give browser time to process scroll + Process.sleep(100) + final_scroll = Page.evaluate(page, "() => window.scrollY") + + assert final_scroll > initial_scroll + end + end +end diff --git a/test/api/network_test.exs b/test/api/network_test.exs index e3485a22..aea126ea 100644 --- a/test/api/network_test.exs +++ b/test/api/network_test.exs @@ -1,6 +1,6 @@ defmodule Playwright.NetworkTest do use Playwright.TestCase, async: true - alias Playwright.Page + alias Playwright.{Page, Route} describe "Page network events" do test "events are fired in the proper order", %{assets: assets, page: page} do @@ -42,4 +42,22 @@ defmodule Playwright.NetworkTest do assert_next_receive({:response, %Page{}}) end end + + describe "Route.abort/2" do + test "aborts the request", %{assets: assets, page: page} do + Page.route(page, "**/empty.html", fn route, _request -> + Route.abort(route) + end) + + assert {:error, _} = Page.goto(page, assets.empty) + end + + test "aborts with error code", %{assets: assets, page: page} do + Page.route(page, "**/empty.html", fn route, _request -> + Route.abort(route, "failed") + end) + + assert {:error, _} = Page.goto(page, assets.empty) + end + end end diff --git a/test/api/page/navigation_test.exs b/test/api/page/navigation_test.exs new file mode 100644 index 00000000..aeab4288 --- /dev/null +++ b/test/api/page/navigation_test.exs @@ -0,0 +1,64 @@ +defmodule Playwright.Page.NavigationTest do + use Playwright.TestCase, async: true + alias Playwright.Page + + describe "Page.go_back/2" do + test "navigates back in history", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + Page.goto(page, assets.dom) + + assert String.ends_with?(Page.url(page), "/dom.html") + + Page.go_back(page) + assert String.ends_with?(Page.url(page), "/empty.html") + end + + test "returns nil when no history", %{page: page} do + assert Page.go_back(page) == nil + end + end + + describe "Page.go_forward/2" do + test "navigates forward in history", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + Page.goto(page, assets.dom) + Page.go_back(page) + + assert String.ends_with?(Page.url(page), "/empty.html") + + Page.go_forward(page) + assert String.ends_with?(Page.url(page), "/dom.html") + end + + test "returns nil when no forward history", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + assert Page.go_forward(page) == nil + end + end + + describe "Page.wait_for_url/3" do + test "resolves immediately if URL already matches", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + result = Page.wait_for_url(page, "**/empty.html") + assert %Page{} = result + end + + test "matches with regex pattern", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + result = Page.wait_for_url(page, ~r/empty\.html$/) + assert %Page{} = result + end + + test "matches with function predicate", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + result = Page.wait_for_url(page, fn url -> String.contains?(url, "empty") end) + assert %Page{} = result + end + + test "times out when URL does not match", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + result = Page.wait_for_url(page, "**/nonexistent.html", %{timeout: 100}) + assert {:error, %{message: "Timeout waiting for URL to match pattern"}} = result + end + end +end diff --git a/test/api/page_frame_test.exs b/test/api/page_frame_test.exs new file mode 100644 index 00000000..cf1c2a7b --- /dev/null +++ b/test/api/page_frame_test.exs @@ -0,0 +1,119 @@ +defmodule Playwright.PageFrameTest do + use Playwright.TestCase, async: true + alias Playwright.{Frame, Page} + + describe "Page.frame/2 by name" do + test "finds frame by name string", %{page: page, assets: assets} do + Page.set_content(page, """ + + """) + + Page.wait_for_load_state(page, "load") + + frame = Page.frame(page, "my-frame") + + assert %Frame{} = frame + assert Frame.name(frame) == "my-frame" + end + + test "finds frame by name in map", %{page: page, assets: assets} do + Page.set_content(page, """ + + """) + + Page.wait_for_load_state(page, "load") + + frame = Page.frame(page, %{name: "named-frame"}) + + assert %Frame{} = frame + assert Frame.name(frame) == "named-frame" + end + + test "returns nil when no frame matches name", %{page: page} do + Page.set_content(page, "

No frames

") + + assert Page.frame(page, "nonexistent") == nil + end + end + + describe "Page.frame/2 by URL" do + test "finds frame by exact URL", %{page: page, assets: assets} do + Page.set_content(page, """ + + """) + + Page.wait_for_load_state(page, "load") + + frame = Page.frame(page, %{url: assets.empty}) + + assert %Frame{} = frame + assert frame.url == assets.empty + end + + test "finds frame by glob pattern", %{page: page, assets: assets} do + Page.set_content(page, """ + + """) + + Page.wait_for_load_state(page, "load") + + frame = Page.frame(page, %{url: "**/empty.html"}) + + assert %Frame{} = frame + assert frame.url =~ "empty.html" + end + + test "finds frame by regex", %{page: page, assets: assets} do + Page.set_content(page, """ + + """) + + Page.wait_for_load_state(page, "load") + + frame = Page.frame(page, %{url: ~r/.*empty\.html$/}) + + assert %Frame{} = frame + assert frame.url =~ "empty.html" + end + + test "finds frame by predicate function", %{page: page, assets: assets} do + Page.set_content(page, """ + + """) + + Page.wait_for_load_state(page, "load") + + frame = Page.frame(page, %{url: fn url -> String.ends_with?(url, "empty.html") end}) + + assert %Frame{} = frame + assert frame.url =~ "empty.html" + end + + test "returns nil when no frame matches URL", %{page: page, assets: assets} do + Page.set_content(page, """ + + """) + + Page.wait_for_load_state(page, "load") + + assert Page.frame(page, %{url: "**/nonexistent.html"}) == nil + end + end + + describe "Page.frame/2 with multiple frames" do + test "finds correct frame among multiple", %{page: page, assets: assets} do + Page.set_content(page, """ + + + """) + + Page.wait_for_load_state(page, "load") + + frame = Page.frame(page, "second") + + assert %Frame{} = frame + assert Frame.name(frame) == "second" + assert frame.url =~ "dom.html" + end + end +end diff --git a/test/api/page_opener_test.exs b/test/api/page_opener_test.exs new file mode 100644 index 00000000..2a952df8 --- /dev/null +++ b/test/api/page_opener_test.exs @@ -0,0 +1,16 @@ +defmodule Playwright.PageOpenerTest do + use Playwright.TestCase, async: true + alias Playwright.Page + + describe "Page.opener/1" do + test "returns nil for regular page", %{page: page} do + assert Page.opener(page) == nil + end + + test "returns nil for page created via new_page", %{browser: browser} do + {:ok, page} = Playwright.Browser.new_page(browser) + assert Page.opener(page) == nil + Page.close(page) + end + end +end diff --git a/test/api/page_query_test.exs b/test/api/page_query_test.exs new file mode 100644 index 00000000..d0c59687 --- /dev/null +++ b/test/api/page_query_test.exs @@ -0,0 +1,51 @@ +defmodule Playwright.PageQueryTest do + use Playwright.TestCase, async: true + alias Playwright.Page + + describe "Page query methods" do + test "inner_text/3", %{page: page} do + Page.set_content(page, "
Hello World
") + assert Page.inner_text(page, "#target") == "Hello World" + end + + test "inner_html/3", %{page: page} do + Page.set_content(page, "
Hello World
") + assert Page.inner_html(page, "#target") == "Hello World" + end + + test "input_value/3", %{page: page} do + Page.set_content(page, "") + assert Page.input_value(page, "#target") == "test value" + end + + test "is_checked/3", %{page: page} do + Page.set_content(page, "") + assert Page.is_checked(page, "#target") == true + end + + test "is_disabled/3", %{page: page} do + Page.set_content(page, "") + assert Page.is_disabled(page, "#target") == true + end + + test "is_editable/3", %{page: page} do + Page.set_content(page, "") + assert Page.is_editable(page, "#target") == true + end + + test "is_enabled/3", %{page: page} do + Page.set_content(page, "") + assert Page.is_enabled(page, "#target") == true + end + + test "is_hidden/3", %{page: page} do + Page.set_content(page, "
Hidden
") + assert Page.is_hidden(page, "#target") == true + end + + test "is_visible/3", %{page: page} do + Page.set_content(page, "
Visible
") + assert Page.is_visible(page, "#target") == true + end + end +end diff --git a/test/api/page_test.exs b/test/api/page_test.exs index d539f4dc..b6f8de5f 100644 --- a/test/api/page_test.exs +++ b/test/api/page_test.exs @@ -4,6 +4,43 @@ defmodule Playwright.PageTest do alias Playwright.SDK.Channel alias Playwright.SDK.Channel.{Error, Event} + describe "Page.check/3 and Page.uncheck/3" do + test "checks and unchecks a checkbox", %{page: page} do + Page.set_content(page, ~s||) + + refute Page.is_checked(page, "#agree") + + Page.check(page, "#agree") + assert Page.is_checked(page, "#agree") + + Page.uncheck(page, "#agree") + refute Page.is_checked(page, "#agree") + end + end + + describe "Page.set_checked/4" do + test "sets checkbox state based on boolean", %{page: page} do + Page.set_content(page, ~s||) + + Page.set_checked(page, "#agree", true) + assert Page.is_checked(page, "#agree") + + Page.set_checked(page, "#agree", false) + refute Page.is_checked(page, "#agree") + end + end + + describe "Page.set_input_files/4" do + test "sets files on a file input", %{page: page} do + Page.set_content(page, ~s||) + + Page.set_input_files(page, "#upload", "test/support/fixtures/file-to-upload.txt") + + filename = Page.eval_on_selector(page, "#upload", "e => e.files[0].name") + assert filename == "file-to-upload.txt" + end + end + describe "Page.drag_and_drop/4" do test "returns 'subject'", %{assets: assets, page: page} do page |> Page.goto(assets.prefix <> "/drag-n-drop.html") @@ -79,7 +116,7 @@ defmodule Playwright.PageTest do describe "Page.on/3" do @tag exclude: [:page] test "on :close (atom)", %{browser: browser} do - page = Browser.new_page(browser) + {:ok, page} = Browser.new_page(browser) this = self() guid = page.guid @@ -93,7 +130,7 @@ defmodule Playwright.PageTest do @tag exclude: [:page] test "on 'close' (string)", %{browser: browser} do - page = Browser.new_page(browser) + {:ok, page} = Browser.new_page(browser) this = self() guid = page.guid @@ -111,8 +148,8 @@ defmodule Playwright.PageTest do test "on 'close' of one Page does not affect another", %{browser: browser} do this = self() - %{guid: guid_one} = page_one = Browser.new_page(browser) - %{guid: guid_two} = page_two = Browser.new_page(browser) + {:ok, %{guid: guid_one} = page_one} = Browser.new_page(browser) + {:ok, %{guid: guid_two} = page_two} = Browser.new_page(browser) Page.on(page_one, "close", fn %{target: target} -> send(this, target.guid) @@ -351,7 +388,7 @@ defmodule Playwright.PageTest do describe "Page.close/1" do @tag without: [:page] test "removes the Page", %{browser: browser} do - page = Browser.new_page(browser) + {:ok, page} = Browser.new_page(browser) assert %Page{} = Channel.find(page.session, {:guid, page.guid}) page |> Page.close() @@ -379,6 +416,20 @@ defmodule Playwright.PageTest do end end + describe "Page.eval_on_selector_all/4" do + test "evaluates on all matching elements", %{page: page} do + Page.set_content(page, "
A
B
C
") + result = Page.eval_on_selector_all(page, ".item", "elements => elements.map(e => e.textContent)") + assert result == ["A", "B", "C"] + end + + test "returns empty array when no matches", %{page: page} do + Page.set_content(page, "
Hello
") + result = Page.eval_on_selector_all(page, ".nonexistent", "elements => elements.length") + assert result == 0 + end + end + describe "Page.fill/3" do test "sets text content", %{assets: assets, page: page} do page @@ -505,4 +556,151 @@ defmodule Playwright.PageTest do assert Page.text_content(page, "span.inner") == "target" end end + + describe "Page.bring_to_front/1" do + test "brings the page to front", %{page: page} do + assert :ok = Page.bring_to_front(page) + end + end + + describe "Page.set_default_timeout/2" do + test "sets the default timeout", %{page: page} do + assert :ok = Page.set_default_timeout(page, 30_000) + end + end + + describe "Page.set_default_navigation_timeout/2" do + test "sets the default navigation timeout", %{page: page} do + assert :ok = Page.set_default_navigation_timeout(page, 60_000) + end + end + + describe "Page.set_extra_http_headers/2" do + test "sets extra HTTP headers", %{page: page} do + assert :ok = Page.set_extra_http_headers(page, %{"X-Custom-Header" => "test-value"}) + end + + test "clears headers with empty map", %{page: page} do + Page.set_extra_http_headers(page, %{"X-Custom" => "value"}) + assert :ok = Page.set_extra_http_headers(page, %{}) + end + end + + describe "Page.viewport_size/1" do + test "returns the current viewport size", %{page: page} do + Page.set_viewport_size(page, %{width: 800, height: 600}) + viewport = Page.viewport_size(page) + + assert viewport.width == 800 + assert viewport.height == 600 + end + + test "returns updated size after set_viewport_size", %{page: page} do + Page.set_viewport_size(page, %{width: 1024, height: 768}) + viewport = Page.viewport_size(page) + + assert viewport.width == 1024 + assert viewport.height == 768 + end + end + + describe "Page.emulate_media/2" do + test "emulates dark color scheme", %{page: page} do + Page.emulate_media(page, %{color_scheme: "dark"}) + result = Page.evaluate(page, "window.matchMedia('(prefers-color-scheme: dark)').matches") + assert result == true + end + + test "emulates light color scheme", %{page: page} do + Page.emulate_media(page, %{color_scheme: "light"}) + result = Page.evaluate(page, "window.matchMedia('(prefers-color-scheme: light)').matches") + assert result == true + end + + test "emulates print media type", %{page: page} do + Page.emulate_media(page, %{media: "print"}) + result = Page.evaluate(page, "window.matchMedia('print').matches") + assert result == true + end + + test "emulates reduced motion", %{page: page} do + Page.emulate_media(page, %{reduced_motion: "reduce"}) + result = Page.evaluate(page, "window.matchMedia('(prefers-reduced-motion: reduce)').matches") + assert result == true + end + + test "can set multiple options at once", %{page: page} do + Page.emulate_media(page, %{color_scheme: "dark", reduced_motion: "reduce"}) + dark = Page.evaluate(page, "window.matchMedia('(prefers-color-scheme: dark)').matches") + reduced = Page.evaluate(page, "window.matchMedia('(prefers-reduced-motion: reduce)').matches") + assert dark == true + assert reduced == true + end + + test "resets options with nil", %{page: page} do + Page.emulate_media(page, %{color_scheme: "dark"}) + assert Page.evaluate(page, "window.matchMedia('(prefers-color-scheme: dark)').matches") == true + + Page.emulate_media(page, %{color_scheme: nil}) + # After reset, should return ok (we can't easily test the actual reset value) + assert :ok = Page.emulate_media(page, %{}) + end + end + + describe "Page.add_script_tag/2" do + test "adds a script tag with content", %{page: page} do + element = Page.add_script_tag(page, %{content: "window.testValue = 42"}) + assert %ElementHandle{} = element + assert Page.evaluate(page, "window.testValue") == 42 + end + + test "adds a script tag with type module", %{page: page} do + element = Page.add_script_tag(page, %{content: "window.moduleTest = 'loaded'", type: "module"}) + assert %ElementHandle{} = element + end + end + + describe "Page.add_style_tag/2" do + test "adds a style tag with content", %{page: page} do + Page.set_content(page, ~s|
Hello
|) + element = Page.add_style_tag(page, %{content: "#target { color: rgb(255, 0, 0); }"}) + assert %ElementHandle{} = element + + color = Page.eval_on_selector(page, "#target", "e => getComputedStyle(e).color") + assert color == "rgb(255, 0, 0)" + end + + test "adds multiple style tags", %{page: page} do + Page.set_content(page, ~s|
Hello
|) + Page.add_style_tag(page, %{content: "#target { color: rgb(0, 255, 0); }"}) + Page.add_style_tag(page, %{content: "#target { background-color: rgb(0, 0, 255); }"}) + + color = Page.eval_on_selector(page, "#target", "e => getComputedStyle(e).color") + bg = Page.eval_on_selector(page, "#target", "e => getComputedStyle(e).backgroundColor") + assert color == "rgb(0, 255, 0)" + assert bg == "rgb(0, 0, 255)" + end + end + + describe "Page.console_messages/1" do + @tag :skip + @tag :requires_playwright_upgrade + test "returns collected console messages", %{page: page} do + # Requires Playwright > 1.49.1 + Page.goto(page, "data:text/html,") + messages = Page.console_messages(page) + assert is_list(messages) + end + end + + describe "Page.page_errors/1" do + @tag :skip + @tag :requires_playwright_upgrade + test "returns collected page errors", %{page: page} do + # Requires Playwright > 1.49.1 + Page.goto(page, "data:text/html,") + errors = Page.page_errors(page) + assert is_list(errors) + end + end end diff --git a/test/api/pdf_test.exs b/test/api/pdf_test.exs new file mode 100644 index 00000000..72ed752d --- /dev/null +++ b/test/api/pdf_test.exs @@ -0,0 +1,53 @@ +defmodule Playwright.PdfTest do + use Playwright.TestCase, async: true + alias Playwright.Page + + describe "Page.pdf/2" do + test "returns PDF binary", %{page: page} do + Page.set_content(page, "

Hello PDF

") + + result = Page.pdf(page) + + # PDF files start with %PDF + assert String.starts_with?(Base.decode64!(result), "%PDF") + end + + test "saves to path", %{page: page} do + Page.set_content(page, "

Hello PDF

") + path = Path.join(System.tmp_dir!(), "test-#{:rand.uniform(10000)}.pdf") + + try do + Page.pdf(page, %{path: path}) + + assert File.exists?(path) + assert String.starts_with?(File.read!(path), "%PDF") + after + File.rm(path) + end + end + + test "with format option", %{page: page} do + Page.set_content(page, "

Hello PDF

") + + result = Page.pdf(page, %{format: "A4"}) + + assert String.starts_with?(Base.decode64!(result), "%PDF") + end + + test "with landscape option", %{page: page} do + Page.set_content(page, "

Hello PDF

") + + result = Page.pdf(page, %{landscape: true}) + + assert String.starts_with?(Base.decode64!(result), "%PDF") + end + + test "with print_background option", %{page: page} do + Page.set_content(page, ~s|
Hello
|) + + result = Page.pdf(page, %{print_background: true}) + + assert String.starts_with?(Base.decode64!(result), "%PDF") + end + end +end diff --git a/test/api/timeout_test.exs b/test/api/timeout_test.exs new file mode 100644 index 00000000..2002160b --- /dev/null +++ b/test/api/timeout_test.exs @@ -0,0 +1,40 @@ +defmodule Playwright.TimeoutTest do + use Playwright.TestCase, async: true + alias Playwright.{BrowserContext, Page} + + describe "Page.set_default_timeout/2" do + test "sets default timeout for page operations", %{page: page} do + assert :ok = Page.set_default_timeout(page, 5000) + end + + test "accepts large timeout values", %{page: page} do + assert :ok = Page.set_default_timeout(page, 120_000) + end + end + + describe "Page.set_default_navigation_timeout/2" do + test "sets default navigation timeout", %{page: page} do + assert :ok = Page.set_default_navigation_timeout(page, 10_000) + end + + test "accepts zero to disable timeout", %{page: page} do + assert :ok = Page.set_default_navigation_timeout(page, 0) + end + end + + describe "BrowserContext.set_default_timeout/2" do + test "sets default timeout on context", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + assert :ok = BrowserContext.set_default_timeout(context, 5000) + BrowserContext.close(context) + end + end + + describe "BrowserContext.set_default_navigation_timeout/2" do + test "sets default navigation timeout on context", %{browser: browser} do + context = Playwright.Browser.new_context(browser) + assert :ok = BrowserContext.set_default_navigation_timeout(context, 10_000) + BrowserContext.close(context) + end + end +end diff --git a/test/api/touchscreen_test.exs b/test/api/touchscreen_test.exs new file mode 100644 index 00000000..d6430af1 --- /dev/null +++ b/test/api/touchscreen_test.exs @@ -0,0 +1,51 @@ +defmodule Playwright.TouchscreenTest do + use Playwright.TestCase, async: true + alias Playwright.{Page, Touchscreen} + + describe "Touchscreen.tap/3" do + @tag :headed + test "dispatches touch events at coordinates", %{browser: browser, assets: _assets} do + # Create a context with touch enabled + context = Playwright.Browser.new_context(browser, %{has_touch: true}) + {:ok, page} = Playwright.BrowserContext.new_page(context) + + Page.set_content(page, """ +
+ + """) + + # Tap at center of target + Touchscreen.tap(page, 50, 50) + + # Check that touch events were received + events = Page.evaluate(page, "() => window.touchEvents") + + assert length(events) == 2 + assert Enum.at(events, 0)["type"] == "touchstart" + assert Enum.at(events, 1)["type"] == "touchend" + + Playwright.BrowserContext.close(context) + end + + test "returns :ok", %{browser: browser} do + # Create a context with touch enabled + context = Playwright.Browser.new_context(browser, %{has_touch: true}) + page = Playwright.BrowserContext.new_page(context) + + Page.set_content(page, "
Hello
") + + result = Touchscreen.tap(page, 10, 10) + assert result == :ok + + Playwright.BrowserContext.close(context) + end + end +end diff --git a/test/api/tracing_test.exs b/test/api/tracing_test.exs new file mode 100644 index 00000000..9bb3e784 --- /dev/null +++ b/test/api/tracing_test.exs @@ -0,0 +1,109 @@ +defmodule Playwright.TracingTest do + use Playwright.TestCase, async: true + alias Playwright.{Browser, BrowserContext, Page, Tracing} + + describe "Tracing" do + test "start and stop without path", %{browser: browser} do + context = Browser.new_context(browser) + tracing = BrowserContext.tracing(context) + + assert :ok = Tracing.start(tracing, %{screenshots: true}) + assert :ok = Tracing.stop(tracing) + + BrowserContext.close(context) + end + + test "start and stop with path", %{page: page} do + context = Page.context(page) + tracing = BrowserContext.tracing(context) + path = Path.join(System.tmp_dir!(), "trace-#{:rand.uniform(10000)}.zip") + + try do + Tracing.start(tracing, %{screenshots: true, snapshots: true}) + Page.set_content(page, "

Hello Tracing

") + Tracing.stop(tracing, %{path: path}) + + assert File.exists?(path) + after + File.rm(path) + end + end + + test "start with name option", %{browser: browser} do + context = Browser.new_context(browser) + tracing = BrowserContext.tracing(context) + + assert :ok = Tracing.start(tracing, %{name: "my-trace", screenshots: true}) + assert :ok = Tracing.stop(tracing) + + BrowserContext.close(context) + end + + test "start_chunk and stop_chunk", %{browser: browser} do + context = Browser.new_context(browser) + tracing = BrowserContext.tracing(context) + path = Path.join(System.tmp_dir!(), "trace-chunk-#{:rand.uniform(10000)}.zip") + + try do + Tracing.start(tracing, %{screenshots: true}) + Tracing.stop_chunk(tracing, %{path: path}) + Tracing.stop(tracing) + + assert File.exists?(path) + after + File.rm(path) + BrowserContext.close(context) + end + end + + test "group and group_end", %{browser: browser} do + context = Browser.new_context(browser) + tracing = BrowserContext.tracing(context) + + Tracing.start(tracing, %{screenshots: true}) + assert :ok = Tracing.group(tracing, "My Group") + assert :ok = Tracing.group_end(tracing) + Tracing.stop(tracing) + + BrowserContext.close(context) + end + + test "group with location option", %{browser: browser} do + context = Browser.new_context(browser) + tracing = BrowserContext.tracing(context) + + Tracing.start(tracing, %{screenshots: true}) + + location = %{file: "test.exs", line: 10, column: 1} + assert :ok = Tracing.group(tracing, "Test Group", %{location: location}) + assert :ok = Tracing.group_end(tracing) + + Tracing.stop(tracing) + BrowserContext.close(context) + end + + test "multiple chunks", %{browser: browser} do + context = Browser.new_context(browser) + tracing = BrowserContext.tracing(context) + path1 = Path.join(System.tmp_dir!(), "trace-chunk1-#{:rand.uniform(10000)}.zip") + path2 = Path.join(System.tmp_dir!(), "trace-chunk2-#{:rand.uniform(10000)}.zip") + + try do + Tracing.start(tracing, %{screenshots: true}) + Tracing.stop_chunk(tracing, %{path: path1}) + + Tracing.start_chunk(tracing, %{title: "Chunk 2"}) + Tracing.stop_chunk(tracing, %{path: path2}) + + Tracing.stop(tracing) + + assert File.exists?(path1) + assert File.exists?(path2) + after + File.rm(path1) + File.rm(path2) + BrowserContext.close(context) + end + end + end +end diff --git a/test/api/unroute_all_test.exs b/test/api/unroute_all_test.exs new file mode 100644 index 00000000..ad250604 --- /dev/null +++ b/test/api/unroute_all_test.exs @@ -0,0 +1,62 @@ +defmodule Playwright.UnrouteAllTest do + use Playwright.TestCase, async: true + alias Playwright.{BrowserContext, Page, Route} + + describe "Page.unroute_all/1" do + test "removes all routes", %{page: page, assets: assets} do + # Add a route that returns a fake response + Page.route(page, "**/*", fn route -> + Route.fulfill(route, %{status: 200, body: "intercepted"}) + end) + + # Remove all routes + Page.unroute_all(page) + + # Navigation should work normally (not intercepted) + response = Page.goto(page, assets.empty) + assert response + end + + test "works with no routes registered", %{page: page, assets: assets} do + # Calling unroute_all with no routes should not error + Page.unroute_all(page) + + response = Page.goto(page, assets.empty) + assert response + end + end + + describe "BrowserContext.unroute_all/1" do + test "removes all context routes", %{browser: browser, assets: assets} do + context = Playwright.Browser.new_context(browser) + + # Add a route that returns a fake response + BrowserContext.route(context, "**/*", fn route -> + Route.fulfill(route, %{status: 200, body: "intercepted"}) + end) + + # Remove all routes + BrowserContext.unroute_all(context) + + # Navigation should work normally (not intercepted) + page = BrowserContext.new_page(context) + response = Page.goto(page, assets.empty) + assert response + + BrowserContext.close(context) + end + + test "works with no routes registered", %{browser: browser, assets: assets} do + context = Playwright.Browser.new_context(browser) + + # Calling unroute_all with no routes should not error + BrowserContext.unroute_all(context) + + page = BrowserContext.new_page(context) + response = Page.goto(page, assets.empty) + assert response + + BrowserContext.close(context) + end + end +end diff --git a/test/api/video_test.exs b/test/api/video_test.exs new file mode 100644 index 00000000..a3eaf57c --- /dev/null +++ b/test/api/video_test.exs @@ -0,0 +1,66 @@ +defmodule Playwright.VideoTest do + use Playwright.TestCase, async: false + alias Playwright.{Browser, BrowserContext, Page, Video} + + describe "Video recording" do + test "saves video to file", %{browser: browser} do + context = Browser.new_context(browser, %{record_video: %{dir: System.tmp_dir!()}}) + page = BrowserContext.new_page(context) + + # Navigate to a page with content and set a visible background to trigger frame capture + Page.goto(page, "data:text/html,

Test

") + Page.evaluate(page, "() => document.body.style.backgroundColor = 'red'") + # Wait for frames to be captured + Process.sleep(200) + + BrowserContext.close(context) + + video = Page.video(page) + assert video != nil + + save_path = Path.join(System.tmp_dir!(), "test_video_#{:rand.uniform(100_000)}.webm") + assert :ok = Video.save_as(video, save_path) + assert File.exists?(save_path) + + File.rm(save_path) + end + + test "returns nil when video not enabled", %{page: page} do + # For pages without video recording, should return nil quickly + assert Page.video(page) == nil + end + + test "delete removes video", %{browser: browser} do + context = Browser.new_context(browser, %{record_video: %{dir: System.tmp_dir!()}}) + page = BrowserContext.new_page(context) + + Page.goto(page, "data:text/html,

Test

") + Page.evaluate(page, "() => document.body.style.backgroundColor = 'blue'") + Process.sleep(200) + + BrowserContext.close(context) + + video = Page.video(page) + assert video != nil + assert :ok = Video.delete(video) + end + + test "path returns video file path", %{browser: browser} do + context = Browser.new_context(browser, %{record_video: %{dir: System.tmp_dir!()}}) + page = BrowserContext.new_page(context) + + Page.goto(page, "data:text/html,

Test

") + Page.evaluate(page, "() => document.body.style.backgroundColor = 'green'") + Process.sleep(200) + + BrowserContext.close(context) + + video = Page.video(page) + assert video != nil + + path = Video.path(video) + assert is_binary(path) + assert String.ends_with?(path, ".webm") + end + end +end diff --git a/test/api/wait_for_navigation_test.exs b/test/api/wait_for_navigation_test.exs new file mode 100644 index 00000000..65d7e658 --- /dev/null +++ b/test/api/wait_for_navigation_test.exs @@ -0,0 +1,128 @@ +defmodule Playwright.WaitForNavigationTest do + use Playwright.TestCase, async: true + alias Playwright.{Frame, Page} + + describe "Frame.wait_for_navigation/3" do + test "waits for navigation triggered by click", %{assets: assets, page: page} do + Page.set_content(page, ~s|link|) + frame = Page.main_frame(page) + + result = + Frame.wait_for_navigation(frame, fn -> + Page.click(page, "a") + end) + + assert %Frame{} = result + assert String.ends_with?(Frame.url(result), "/empty.html") + end + + test "waits with URL glob pattern", %{assets: assets, page: page} do + Page.set_content(page, ~s|link|) + frame = Page.main_frame(page) + + result = + Frame.wait_for_navigation(frame, %{url: "**/empty.html"}, fn -> + Page.click(page, "a") + end) + + assert %Frame{} = result + end + + test "waits with regex URL pattern", %{assets: assets, page: page} do + Page.set_content(page, ~s|link|) + frame = Page.main_frame(page) + + result = + Frame.wait_for_navigation(frame, %{url: ~r/empty\.html$/}, fn -> + Page.click(page, "a") + end) + + assert %Frame{} = result + end + + test "waits with function predicate", %{assets: assets, page: page} do + Page.set_content(page, ~s|link|) + frame = Page.main_frame(page) + + result = + Frame.wait_for_navigation(frame, %{url: fn url -> String.contains?(url, "empty") end}, fn -> + Page.click(page, "a") + end) + + assert %Frame{} = result + end + + test "times out when no navigation occurs", %{page: page} do + Page.set_content(page, "
no links
") + frame = Page.main_frame(page) + + result = + Frame.wait_for_navigation(frame, %{timeout: 100}, fn -> + # Do nothing that would trigger navigation + :ok + end) + + assert {:error, _} = result + end + + test "respects wait_until option", %{assets: assets, page: page} do + Page.set_content(page, ~s|link|) + frame = Page.main_frame(page) + + result = + Frame.wait_for_navigation(frame, %{wait_until: "domcontentloaded"}, fn -> + Page.click(page, "a") + end) + + assert %Frame{} = result + end + + test "works with goto navigation", %{assets: assets, page: page} do + frame = Page.main_frame(page) + + result = + Frame.wait_for_navigation(frame, fn -> + Page.goto(page, assets.empty) + end) + + assert %Frame{} = result + assert String.ends_with?(Frame.url(result), "/empty.html") + end + end + + describe "Page.wait_for_navigation/3" do + test "waits for navigation and returns page", %{assets: assets, page: page} do + Page.set_content(page, ~s|link|) + + result = + Page.wait_for_navigation(page, fn -> + Page.click(page, "a") + end) + + assert %Page{} = result + assert String.ends_with?(Page.url(result), "/empty.html") + end + + test "waits with URL pattern", %{assets: assets, page: page} do + Page.set_content(page, ~s|link|) + + result = + Page.wait_for_navigation(page, %{url: "**/empty.html"}, fn -> + Page.click(page, "a") + end) + + assert %Page{} = result + end + + test "times out when no navigation occurs", %{page: page} do + Page.set_content(page, "
no links
") + + result = + Page.wait_for_navigation(page, %{timeout: 100}, fn -> + :ok + end) + + assert {:error, _} = result + end + end +end diff --git a/test/api/wait_for_network_test.exs b/test/api/wait_for_network_test.exs new file mode 100644 index 00000000..6404581a --- /dev/null +++ b/test/api/wait_for_network_test.exs @@ -0,0 +1,99 @@ +defmodule Playwright.WaitForNetworkTest do + use Playwright.TestCase, async: true + alias Playwright.{Page, Request, Response} + + describe "wait_for_request/4" do + test "waits for request matching glob pattern", %{page: page, assets: assets} do + request = + Page.wait_for_request(page, "**/empty.html", %{}, fn -> + Page.goto(page, assets.empty) + end) + + assert %Request{} = request + assert request.url =~ "empty.html" + end + + test "waits for request matching regex", %{page: page, assets: assets} do + request = + Page.wait_for_request(page, ~r/empty\.html$/, %{}, fn -> + Page.goto(page, assets.empty) + end) + + assert %Request{} = request + assert String.ends_with?(request.url, "empty.html") + end + + test "waits for request matching predicate function", %{page: page, assets: assets} do + request = + Page.wait_for_request( + page, + fn req -> req.method == "GET" and String.contains?(req.url, "empty") end, + %{}, + fn -> Page.goto(page, assets.empty) end + ) + + assert %Request{} = request + assert request.method == "GET" + end + + test "times out when no matching request", %{page: page} do + result = Page.wait_for_request(page, "**/nonexistent-path-12345", %{timeout: 500}) + + assert {:error, _} = result + end + end + + describe "wait_for_response/4" do + test "waits for response matching glob pattern", %{page: page, assets: assets} do + response = + Page.wait_for_response(page, "**/empty.html", %{}, fn -> + Page.goto(page, assets.empty) + end) + + assert %Response{} = response + assert response.url =~ "empty.html" + assert response.status == 200 + end + + test "waits for response matching regex", %{page: page, assets: assets} do + response = + Page.wait_for_response(page, ~r/empty\.html$/, %{}, fn -> + Page.goto(page, assets.empty) + end) + + assert %Response{} = response + assert String.ends_with?(response.url, "empty.html") + end + + test "waits for response matching predicate checking status", %{page: page, assets: assets} do + response = + Page.wait_for_response( + page, + fn resp -> resp.status == 200 and String.contains?(resp.url, "empty") end, + %{}, + fn -> Page.goto(page, assets.empty) end + ) + + assert %Response{} = response + assert response.status == 200 + end + + test "times out when no matching response", %{page: page} do + result = Page.wait_for_response(page, "**/nonexistent-path-12345", %{timeout: 500}) + + assert {:error, _} = result + end + + test "can access response body after waiting", %{page: page, assets: assets} do + response = + Page.wait_for_response(page, "**/dom.html", %{}, fn -> + Page.goto(page, assets.dom) + end) + + assert %Response{} = response + body = Response.text(response) + assert is_binary(body) + assert String.length(body) > 0 + end + end +end diff --git a/test/api/web_socket_route_test.exs b/test/api/web_socket_route_test.exs new file mode 100644 index 00000000..3dd8d75f --- /dev/null +++ b/test/api/web_socket_route_test.exs @@ -0,0 +1,219 @@ +defmodule Playwright.WebSocketRouteTest do + use Playwright.TestCase, transport: :driver + + alias Playwright.{Page, WebSocketRoute} + + describe "Page.route_web_socket/3" do + test "intercepts WebSocket connections", %{assets: assets, page: page} do + test_pid = self() + + Page.route_web_socket(page, "**/*", fn ws_route -> + send(test_pid, {:ws_route, ws_route}) + end) + + Page.goto(page, assets.empty) + + result = + Page.evaluate(page, """ + () => { + return new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:9999/ws'); + ws.onopen = () => resolve('opened'); + ws.onerror = () => resolve('error'); + setTimeout(() => resolve('timeout'), 3000); + }); + } + """) + + assert result == "opened" + assert_receive {:ws_route, %WebSocketRoute{}}, 1000 + end + + test "handler receives correct URL", %{assets: assets, page: page} do + test_pid = self() + + Page.route_web_socket(page, "**/my-websocket", fn ws_route -> + send(test_pid, {:url, WebSocketRoute.url(ws_route)}) + end) + + Page.goto(page, assets.empty) + + Page.evaluate(page, """ + () => { + return new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:9999/my-websocket'); + ws.onopen = () => resolve('opened'); + ws.onerror = () => resolve('error'); + setTimeout(() => resolve('timeout'), 3000); + }); + } + """) + + assert_receive {:url, url}, 5000 + assert String.ends_with?(url, "/my-websocket") + end + + test "can send message to page", %{assets: assets, page: page} do + Page.route_web_socket(page, "**/ws", fn ws_route -> + Task.start(fn -> + Process.sleep(100) + WebSocketRoute.send(ws_route, "hello from server") + end) + end) + + Page.goto(page, assets.empty) + + result = + Page.evaluate(page, """ + () => { + return new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:9999/ws'); + ws.onmessage = (event) => resolve(event.data); + ws.onerror = () => resolve('error'); + setTimeout(() => resolve('timeout'), 3000); + }); + } + """) + + assert result == "hello from server" + end + + test "can receive message from page", %{assets: assets, page: page} do + test_pid = self() + + Page.route_web_socket(page, "**/ws", fn ws_route -> + WebSocketRoute.on_message(ws_route, fn message -> + send(test_pid, {:page_message, message}) + end) + end) + + Page.goto(page, assets.empty) + + Page.evaluate(page, """ + () => { + return new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:9999/ws'); + ws.onopen = () => { + ws.send('hello from page'); + resolve('sent'); + }; + ws.onerror = () => resolve('error'); + setTimeout(() => resolve('timeout'), 3000); + }); + } + """) + + assert_receive {:page_message, "hello from page"}, 5000 + end + + test "can mock echo server", %{assets: assets, page: page} do + Page.route_web_socket(page, "**/echo", fn ws_route -> + WebSocketRoute.on_message(ws_route, fn message -> + WebSocketRoute.send(ws_route, "echo: #{message}") + end) + end) + + Page.goto(page, assets.empty) + + result = + Page.evaluate(page, """ + () => { + return new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:9999/echo'); + ws.onopen = () => ws.send('test message'); + ws.onmessage = (event) => resolve(event.data); + ws.onerror = () => resolve('error'); + setTimeout(() => resolve('timeout'), 3000); + }); + } + """) + + assert result == "echo: test message" + end + + test "supports regex patterns", %{assets: assets, page: page} do + test_pid = self() + + Page.route_web_socket(page, ~r/.*\/ws-\d+/, fn ws_route -> + send(test_pid, {:matched, WebSocketRoute.url(ws_route)}) + end) + + Page.goto(page, assets.empty) + + Page.evaluate(page, """ + () => { + return new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:9999/ws-123'); + ws.onopen = () => resolve('opened'); + ws.onerror = () => resolve('error'); + setTimeout(() => resolve('timeout'), 3000); + }); + } + """) + + assert_receive {:matched, url}, 5000 + assert String.ends_with?(url, "/ws-123") + end + end + + describe "WebSocketRoute.close/2" do + test "can close the connection", %{assets: assets, page: page} do + Page.route_web_socket(page, "**/ws", fn ws_route -> + # Close the connection after a brief delay to allow the socket to open + Task.start(fn -> + Process.sleep(200) + WebSocketRoute.close(ws_route, %{code: 1000, reason: "done"}) + end) + end) + + Page.goto(page, assets.empty) + + result = + Page.evaluate(page, """ + () => { + return new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:9999/ws'); + ws.onopen = () => console.log('opened'); + ws.onclose = (event) => resolve({code: event.code, reason: event.reason, wasClean: event.wasClean}); + ws.onerror = (e) => resolve({error: true, message: e.message || 'unknown'}); + setTimeout(() => resolve({timeout: true}), 5000); + }); + } + """) + + # The close event should be received + assert result[:code] == 1000 + assert result[:reason] == "done" + end + end + + describe "WebSocketRoute.on_close/2" do + test "receives close event from page", %{assets: assets, page: page} do + test_pid = self() + + Page.route_web_socket(page, "**/ws", fn ws_route -> + WebSocketRoute.on_close(ws_route, fn code, reason -> + send(test_pid, {:closed, code, reason}) + end) + end) + + Page.goto(page, assets.empty) + + Page.evaluate(page, """ + () => { + return new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:9999/ws'); + ws.onopen = () => { + ws.close(1000, 'goodbye'); + resolve('closed'); + }; + ws.onerror = () => resolve('error'); + setTimeout(() => resolve('timeout'), 3000); + }); + } + """) + + assert_receive {:closed, 1000, "goodbye"}, 5000 + end + end +end diff --git a/test/playwright_test.exs b/test/playwright_test.exs index 29517947..ec3d5b46 100644 --- a/test/playwright_test.exs +++ b/test/playwright_test.exs @@ -6,9 +6,8 @@ defmodule Playwright.PlaywrightTest do describe "Playwright.connect/2" do @tag :ws test "with :chromium" do - with {:ok, browser} <- Playwright.connect(:chromium) do - page = Browser.new_page(browser) - + with {:ok, browser} <- Playwright.connect(:chromium), + {:ok, page} <- Browser.new_page(browser) do assert page |> Page.goto("https://www.whatsmybrowser.org") |> Response.ok() @@ -19,9 +18,8 @@ defmodule Playwright.PlaywrightTest do @tag :ws test "with :firefox" do - with {:ok, browser} <- Playwright.connect(:firefox) do - page = Browser.new_page(browser) - + with {:ok, browser} <- Playwright.connect(:firefox), + {:ok, page} <- Browser.new_page(browser) do assert page |> Page.goto("https://www.whatsmybrowser.org") |> Response.ok() @@ -32,9 +30,8 @@ defmodule Playwright.PlaywrightTest do @tag :ws test "with :webkit" do - with {:ok, browser} <- Playwright.connect(:webkit) do - page = Browser.new_page(browser) - + with {:ok, browser} <- Playwright.connect(:webkit), + {:ok, page} <- Browser.new_page(browser) do assert page |> Page.goto("https://www.whatsmybrowser.org") |> Response.ok() @@ -47,9 +44,9 @@ defmodule Playwright.PlaywrightTest do describe "Playwright.launch/2" do test "launches and returns an instance of the requested Browser" do {:ok, browser} = Playwright.launch(:chromium) + {:ok, page} = Browser.new_page(browser) - assert browser - |> Browser.new_page() + assert page |> Page.goto("http://example.com") |> Response.ok() end @@ -57,8 +54,9 @@ defmodule Playwright.PlaywrightTest do describe "PlaywrightTest.Case context" do test "using `:browser`", %{browser: browser} do - assert browser - |> Browser.new_page() + {:ok, page} = Browser.new_page(browser) + + assert page |> Page.goto("http://example.com") |> Response.ok() end diff --git a/test/support/unit_test.ex b/test/support/unit_case.ex similarity index 100% rename from test/support/unit_test.ex rename to test/support/unit_case.ex