Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
40 changes: 40 additions & 0 deletions lib/playwright_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
137 changes: 136 additions & 1 deletion lib/playwright_ex/channels/browser_type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
55 changes: 55 additions & 0 deletions test/browser_type_test.exs
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -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)