diff --git a/README.md b/README.md index c13b33f..94631cd 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,27 @@ Freddy. {:ok, _} = Frame.goto(frame.guid, "https://elixir-lang.org/", timeout: 1000) {:ok, _} = Frame.click(frame.guid, Selector.link("Install"), timeout: 1000) +## Alternative Launch Methods + +Launch with a persistent context: +Instead of a standard browser, you can launch a persistent context which retains cookies and local storage across sessions using a specified user data directory. + + {:ok, context} = PlaywrightEx.launch_persistent_context(:chromium, + user_data_dir: "/path/to/user/data", + timeout: 1000 + ) + + {:ok, page} = PlaywrightEx.BrowserContext.new_page(context.guid, timeout: 1000) + {:ok, _} = PlaywrightEx.Frame.goto(page.main_frame.guid, "https://elixir-lang.org/", timeout: 1000) + +Connect to an existing browser via CDP: +If you have a browser already running with remote debugging enabled, you can connect to it directly via the Chrome DevTools Protocol (CDP). + + {:ok, browser} = PlaywrightEx.connect_over_cdp(:chromium, + endpoint_url: "ws://localhost:9222", + timeout: 1000 + ) + ## Remove server via Websocket By default, PlaywrightEx launches a local playwright driver. This is typically installed via `npm` or `bun`. diff --git a/lib/playwright_ex.ex b/lib/playwright_ex.ex index 011792c..132a7f8 100644 --- a/lib/playwright_ex.ex +++ b/lib/playwright_ex.ex @@ -40,6 +40,46 @@ defmodule PlaywrightEx do end end + @doc """ + Launches a browser and opens a persistent context using the given user data directory. + + ## Options + + #{NimbleOptions.docs(BrowserType.launch_persistent_context_opts_schema())} + """ + @spec launch_persistent_context(atom(), [BrowserType.launch_persistent_context_opt() | unknown_opt()]) :: + {:ok, %{guid: guid(), tracing: %{guid: guid()}}} | {:error, any()} + def launch_persistent_context(type, opts) do + {connection, opts} = + opts + |> PlaywrightEx.Channel.validate_known!(BrowserType.launch_persistent_context_opts_schema()) + |> Keyword.pop!(:connection) + + playwright_init = Connection.initializer!(connection, "Playwright") + type_id = playwright_init |> Map.fetch!(type) |> Map.fetch!(:guid) + BrowserType.launch_persistent_context(type_id, opts ++ [connection: connection]) + end + + @doc """ + Connects to an existing browser instance using the Chrome DevTools Protocol endpoint. + + ## Options + + #{NimbleOptions.docs(BrowserType.connect_over_cdp_opts_schema())} + """ + @spec connect_over_cdp(atom(), [BrowserType.connect_over_cdp_opt() | unknown_opt()]) :: + {:ok, %{guid: guid()}} | {:error, any()} + def connect_over_cdp(type, opts) do + {connection, opts} = + opts + |> PlaywrightEx.Channel.validate_known!(BrowserType.connect_over_cdp_opts_schema()) + |> Keyword.pop!(:connection) + + playwright_init = Connection.initializer!(connection, "Playwright") + type_id = playwright_init |> Map.fetch!(type) |> Map.fetch!(:guid) + BrowserType.connect_over_cdp(type_id, opts ++ [connection: connection]) + end + subscribe_schema = NimbleOptions.new!( connection: PlaywrightEx.Channel.connection_opt(), diff --git a/lib/playwright_ex/channels/browser_type.ex b/lib/playwright_ex/channels/browser_type.ex index 85ea7b0..e178863 100644 --- a/lib/playwright_ex/channels/browser_type.ex +++ b/lib/playwright_ex/channels/browser_type.ex @@ -47,7 +47,8 @@ defmodule PlaywrightEx.BrowserType do @spec launch(PlaywrightEx.guid(), [launch_opt() | PlaywrightEx.unknown_opt()]) :: {:ok, %{guid: PlaywrightEx.guid()}} | {:error, any()} def launch(type_id, opts \\ []) do - {connection, opts} = opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection) + {connection, opts} = + opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection) {timeout, opts} = Keyword.pop!(opts, :timeout) connection @@ -57,4 +58,138 @@ defmodule PlaywrightEx.BrowserType do @doc false def launch_opts_schema, do: @schema + + schema = + NimbleOptions.new!( + connection: PlaywrightEx.Channel.connection_opt(), + timeout: PlaywrightEx.Channel.timeout_opt(), + user_data_dir: [ + type: :string, + required: true, + doc: "Path to a user data directory, which stores browser session data such as cookies and local storage." + ], + channel: [ + type: :string, + doc: "Browser distribution channel." + ], + executable_path: [ + type: :string, + doc: "Path to a browser executable to run instead of the bundled one." + ], + headless: [ + type: :boolean, + doc: "Whether to run browser in headless mode. Defaults to false for persistent contexts." + ], + slow_mo: [ + type: {:or, [:integer, :float]}, + doc: "Slows down Playwright operations by the specified amount of milliseconds." + ], + accept_downloads: [ + type: :boolean, + doc: "Whether to automatically download all the attachments. Defaults to true." + ], + base_url: [ + type: :string, + doc: "Base URL used with Page.goto/2 and similar navigation functions." + ], + bypass_csp: [ + type: :boolean, + doc: "Toggles bypassing page's Content-Security-Policy. Defaults to false." + ], + locale: [ + type: :string, + doc: "Specify user locale, for example en-GB, de-DE, etc." + ], + user_agent: [ + type: :string, + doc: "Specific user agent to use in this context." + ], + viewport: [ + type: :any, + doc: "Sets a consistent viewport for each page. Map with :width and :height, or nil to disable." + ], + ignore_https_errors: [ + type: :boolean, + doc: "Whether to ignore HTTPS errors when sending network requests." + ], + java_script_enabled: [ + type: :boolean, + doc: "Whether or not to enable JavaScript in the context. Defaults to true." + ] + ) + + @doc """ + Launches a browser and opens a persistent context using the given user data directory. + + Reference: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context + + ## Options + #{NimbleOptions.docs(schema)} + """ + @schema schema + @type launch_persistent_context_opt :: unquote(NimbleOptions.option_typespec(schema)) + @spec launch_persistent_context(PlaywrightEx.guid(), [launch_persistent_context_opt() | PlaywrightEx.unknown_opt()]) :: + {:ok, %{guid: PlaywrightEx.guid(), tracing: %{guid: PlaywrightEx.guid()}}} | {:error, any()} + def launch_persistent_context(type_id, opts \\ []) do + {connection, opts} = + opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection) + {timeout, opts} = Keyword.pop!(opts, :timeout) + + connection + |> Connection.send(%{guid: type_id, method: :launch_persistent_context, params: Map.new(opts)}, timeout) + |> ChannelResponse.unwrap_create(:context, connection) + end + + @doc false + def launch_persistent_context_opts_schema, do: @schema + + schema = + NimbleOptions.new!( + connection: PlaywrightEx.Channel.connection_opt(), + timeout: PlaywrightEx.Channel.timeout_opt(), + endpoint_url: [ + type: :string, + required: true, + doc: "A CDP websocket endpoint or http url to connect to. For example ws://localhost:9222/ or http://localhost:9222/." + ], + headers: [ + type: :any, + doc: "Additional HTTP headers to be sent with connect request. Often used for authorization." + ], + slow_mo: [ + type: {:or, [:integer, :float]}, + doc: "Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going on." + ] + ) + + @doc """ + Connects to an existing browser instance using the Chrome DevTools Protocol endpoint. + + Reference: https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp + + ## Options + #{NimbleOptions.docs(schema)} + """ + @schema schema + @type connect_over_cdp_opt :: unquote(NimbleOptions.option_typespec(schema)) + @spec connect_over_cdp(PlaywrightEx.guid(), [connect_over_cdp_opt() | PlaywrightEx.unknown_opt()]) :: + {:ok, %{guid: PlaywrightEx.guid()}} | {:error, any()} + def connect_over_cdp(type_id, opts \\ []) do + {connection, opts} = + opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection) + {timeout, opts} = Keyword.pop!(opts, :timeout) + + params = + opts + |> Map.new() + |> Map.put(:endpointURL, opts[:endpoint_url]) + |> Map.delete(:endpoint_url) + + connection + |> Connection.send(%{guid: type_id, method: :connectOverCDP, params: params}, timeout) + |> ChannelResponse.unwrap_create(:browser, connection) + end + + @doc false + def connect_over_cdp_opts_schema, do: @schema end diff --git a/test/browser_type_test.exs b/test/browser_type_test.exs new file mode 100644 index 0000000..7e8b951 --- /dev/null +++ b/test/browser_type_test.exs @@ -0,0 +1,55 @@ +defmodule PlaywrightEx.BrowserTypeTest do + use ExUnit.Case, async: true + + alias PlaywrightEx.BrowserContext + alias PlaywrightEx.Frame + + @timeout Application.compile_env(:playwright_ex, :timeout, 5000) + + describe "launch_persistent_context/2" do + test "launches a persistent context with a user data directory" do + user_data_dir = Path.join(System.tmp_dir!(), "pw_ex_test_#{System.unique_integer([:positive])}") + + try do + assert {:ok, context} = + PlaywrightEx.launch_persistent_context(:chromium, + user_data_dir: user_data_dir, + timeout: @timeout + ) + + # Ensure the returned context structure is correct (mimics new_context/2 return shape) + assert %{guid: _, tracing: %{guid: _}} = context + + # Ensure the context is functional by creating a page and navigating + assert {:ok, page} = BrowserContext.new_page(context.guid, timeout: @timeout) + assert {:ok, _} = Frame.goto(page.main_frame.guid, url: "about:blank", timeout: @timeout) + + # Close the context + assert {:ok, _} = BrowserContext.close(context.guid, timeout: @timeout) + + # Ensure Playwright actually wrote to the provided user data dir + assert File.exists?(user_data_dir) + after + File.rm_rf(user_data_dir) + end + end + end + + describe "connect_over_cdp/2" do + test "sends the CDP connection request to Playwright" do + # We cannot easily stand up a CDP endpoint in this test suite without complex setup, + # but we can verify that the protocol command is successfully dispatched + # and handled by Playwright (which will gracefully reject the invalid endpoint). + result = + PlaywrightEx.connect_over_cdp(:chromium, + endpoint_url: "ws://localhost:9999/invalid-cdp-endpoint", + timeout: @timeout + ) + + assert {:error, %{error: %{message: error_message}}} = result + + # The error should confirm that the JS server attempted the method and connection + assert error_message =~ "browserType.connectOverCDP" or error_message =~ "connect ECONNREFUSED" + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 0d5d738..aaa7ee1 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,2 +1,8 @@ ExUnit.start() -{:ok, _} = PlaywrightEx.Supervisor.start_link(Application.get_all_env(:playwright_ex)) + +env = + :playwright_ex + |> Application.get_all_env() + |> Keyword.put_new(:executable, "assets/node_modules/playwright/cli.js") + +{:ok, _} = PlaywrightEx.Supervisor.start_link(env)