Using Playwright in Elixir
A minimalist approach.
Elixir and Phoenix provide an excellent foundation for building robust web applications. But the ecosystem has a notable gap: state of the art cross-browser automation.
Enter Playwright1, Microsoft's modern automation framework that's gaining widespread adoption for its reliability and comprehensive cross-browser support. Elixir developers face a common dilemma - there's no official SDK for our language of choice. In this post, I'll share a pragmatic approach to integrating Playwright with Elixir.
The current landscape
The Elixir community has made attempts to bridge this gap. The playwright-elixir2 project aims to provide native Elixir bindings. But like many such efforts, it remains a work in progress with uncertain completion prospects. This is a common challenge when maintaining language bindings for a complex tool like Playwright.
Saša Jurić makes a similar point regarding Kafka bindings in his talk Aganst the Grain3, although he arrived at a very solution for his problem.
A minimalist alternative
Instead of waiting for a comprehensive suite of Elixir bindings to be finished some time in the future, you can roll your own, tailored, Playwright interface today. Playwright's architecture includes a Node.js server that can be controlled via a simple protocol, making it possible to control browsers with minimal glue code.
I recently opened a pull request in germsvel/phoenix_test#145
4 demonstrating this approach. The implementation requires just three key components:
- A Port connection to the Playwright Node.js server
- A GenServer to manage the connection state
- A minimal wrapper for Playwright internals (Selector, Frame)
With just a few hundred lines of code you can control browsers via Playwright. Compared to a full-blown wrapper like playwright-elixir, this is quite manageable. I copied the message parsing code as is from playwright-elixir - thank you!
Connection and state management
The GenServer-based connection manager handles the asynchronous nature of browser automation:
defmodule Playwright.Connection do
use GenServer
def sync_post(msg) do
timeout = msg[:params][:timeout] + @grace_period
GenServer.call(@name, {:sync_post, msg}, timeout)
end
@impl GenServer
def handle_call({:sync_post, msg}, from, state) do
msg_id = fn -> System.unique_integer([:positive, :monotonic]) end
msg = msg |> Map.new() |> Map.put_new_lazy(:id, msg_id)
PlaywrightPort.post(state.port, msg)
{:noreply, Map.update!(state, :pending_response, &Map.put(&1, msg.id, from))}
end
@impl GenServer
def handle_info({_, {:data, _}} = raw_msg, state) do
{port, msgs} = PlaywrightPort.parse(state.port, raw_msg)
state = %{state | port: port}
state = Enum.reduce(msgs, state, &handle_recv/2)
{:noreply, state}
end
defp handle_recv(msg, %{pending_response: pending} = state)
when is_map_key(pending, msg.id) do
{from, pending} = Map.pop(pending, msg.id)
GenServer.reply(from, msg)
%{state | pending_response: pending}
end
end
Interacting with the browser
Two Playwright concepts are essential for interacting with the browser:
- Selector: A DSL for finding elements within a page
- Frame: User interactions are usually simulated by interacting with the "main frame" - think of it as your browser window
Playwright supports different types of selectors: CSS, XPath, and internal selectors. These can be mixed and matched by chaining them together. You can also register custom selector engines5 that run right in the browser (Javascript).
defmodule Playwright.Selector do
def concat(left, :none), do: left
def concat(left, right), do: "#{left} >> #{right}"
def label(label, exact: true), do: "internal:label=\"#{label}\"s"
def label(label, exact: false), do: "internal:label=\"#{label}\"i"
end
There is no official documentation, since this is considered Playwright internal. Take a look at the playwright docs and source code to find out more 6.
Interaction with the browser usually happens via the Frame:
goto
a URLexpect
an element to be visiblefill
in a text field
defmodule Playwright.Frame do
def goto(frame_id, url) do
params = %{url: url}
sync_post(guid: frame_id, method: :goto, params: params)
:ok
end
def expect(frame_id, params) do
[guid: frame_id, method: :expect, params: params]
|> sync_post()
|> unwrap_response(& &1.result.matches)
end
def fill(frame_id, selector, value, opts \\ []) do
params = %{selector: selector, value: value, strict: true}
params = Enum.into(opts, params)
[guid: frame_id, method: :fill, params: params]
|> sync_post()
|> unwrap_response(& &1)
end
defp unwrap_response(response, fun) do
case response do
%{error: %{error: error}} -> {:error, error}
_ -> {:ok, fun.(response)}
end
end
end
Integration with PhoenixTest
See how this approach is applied to PhoenixTest4:
defmodule MyFeatureTest do
use PhoenixTest.Case,
async: true,
parameterize: [
%{playwright: [browser: :chromium]},
%{playwright: [browser: :firefox]}
]
test "feature", %{conn: conn} do
conn
|> visit("/")
|> assert_has("h1", text: "Welcome")
end
end
This approach provides a familiar testing experience while leveraging Playwright's powerful browser automation capabilities.
Conclusion
The lack of an official Playwright SDK for Elixir isn't a roadblock - it's an opportunity to embrace simplicity. With just a few hundred lines of code, we can create a robust browser automation solution that's easy to implement, understand and maintain. This approach demonstrates a broader pattern in Elixir development: sometimes the most elegant solutions come from directly integrating external tools rather than using a wrapper.
What challenges have you faced with browser automation in your Elixir projects?
This post was written with the help of claude.ai
Footnotes
-
GitHub: playwright-elixir ↩
-
YouTube: Saša Jurić - Against the Grain ↩
-
GitHub: phoenix_test playwright PR ↩ ↩2
-
Playwright: custom selector engines ↩
-
Playwright: locator docs - locator.ts - locatorUtils.ts - frame.ts ↩