Skip to content

Instantly share code, notes, and snippets.

@ityonemo
Created April 21, 2026 22:25
Show Gist options
  • Select an option

  • Save ityonemo/57ba241986ea8b6f5a4bb73615eeefbb to your computer and use it in GitHub Desktop.

Select an option

Save ityonemo/57ba241986ea8b6f5a4bb73615eeefbb to your computer and use it in GitHub Desktop.
Simple Elixir Playwright integration
defmodule Integration.Playwright do
@moduledoc """
Runs Playwright scripts with sandbox metadata injection.
Uses a persistent browser server for efficiency - browser contexts are cheap
(~50ms) while browser launches are expensive (~1-2s).
## Setup
Call `Playwright.start()` once in integration/test_helper.exs.
## Usage
Include `userId` in the context to automatically authenticate:
Playwright.run!(~S\"""
await page.goto('/notes');
await expect(page.locator('h1')).toContainText('Notes');
\""", %{userId: user.id})
"""
require Logger
@port 4444
@server_path Path.expand("../integration/playwright/server.js", __DIR__)
@request_timeout 30_000
@doc """
Starts the Playwright browser server. Call once in test_helper.exs.
"""
def start do
node_path = find_node_path()
env =
case node_path do
nil -> []
path -> [{"NODE_PATH", path}]
end
# Start server in background with MuonTrap for cleanup
Task.start(fn ->
MuonTrap.cmd("node", [@server_path, Integer.to_string(@port)],
stderr_to_stdout: true,
env: env
)
end)
# Wait for server to be ready
wait_for_server()
Logger.info("[Playwright] Server started on port #{@port}")
end
@doc """
Attaches a telemetry handler for this test. Returns handler ID for detaching.
"""
def attach_telemetry(test_pid, sandbox_metadata) do
handler_id = "lv-tracker-#{:erlang.unique_integer()}"
:telemetry.attach(
handler_id,
[:phoenix, :live_view, :mount, :stop],
fn _event, _measurements, %{socket: socket}, _config ->
if socket.assigns[:phoenix_ecto_sandbox] == sandbox_metadata do
send(test_pid, {:liveview_mounted, self()})
end
end,
nil
)
handler_id
end
@doc """
Detaches the telemetry handler.
"""
def detach_telemetry(handler_id) do
:telemetry.detach(handler_id)
end
@doc """
Collects all mounted LiveView PIDs and waits for them to terminate.
"""
def await_liveviews do
# Collect PIDs over a short window to catch late-mounting LiveViews
pids = collect_liveview_pids([], System.monotonic_time(:millisecond) + 200)
refs = Enum.map(pids, &Process.monitor/1)
:timer.send_after(5000, :stop_await)
await_all_down(refs)
end
defp collect_liveview_pids(acc, deadline) do
remaining = deadline - System.monotonic_time(:millisecond)
if remaining <= 0 do
acc
else
receive do
{:liveview_mounted, pid} -> collect_liveview_pids([pid | acc], deadline)
after
remaining -> acc
end
end
end
defp await_all_down([]), do: []
defp await_all_down(refs) do
receive do
{:DOWN, ref, :process, _, _} ->
if ref in refs do
await_all_down(refs -- [ref])
end
:stop_await ->
raise "some pids not down"
end
end
@doc """
Runs a Playwright script with the given context.
If `userId` is present in the context, automatically logs in first.
"""
def run!(script, context \\ %{}) do
script = maybe_prepend_login(script, context)
{:ok, socket} =
:gen_tcp.connect(~c"127.0.0.1", @port, [:binary, active: false, packet: :raw], 5_000)
try do
sandbox_metadata = Process.get(:sandbox_metadata)
execute_script(socket, script, context, sandbox_metadata)
after
:gen_tcp.close(socket)
end
# Wait for any LiveViews spawned by this test to terminate
await_liveviews()
end
defp execute_script(socket, script, context, sandbox_metadata) do
payload = %{
script: script,
context: context,
baseUrl: "http://127.0.0.1:4002",
sandboxMetadata: sandbox_metadata
}
json = Jason.encode!(payload)
length_prefix = String.pad_leading(Integer.to_string(byte_size(json)), 4, "0")
:ok = :gen_tcp.send(socket, length_prefix <> json)
case recv_response(socket) do
{:ok, %{"success" => true}} -> :ok
{:ok, %{"success" => false, "error" => error}} -> raise "Playwright script failed:\n#{error}"
{:error, reason} -> raise "Playwright communication error: #{inspect(reason)}"
end
end
defp recv_response(socket) do
case :gen_tcp.recv(socket, 4, @request_timeout) do
{:ok, length_str} ->
length = String.to_integer(length_str)
case :gen_tcp.recv(socket, length, @request_timeout) do
{:ok, json} -> {:ok, Jason.decode!(json)}
{:error, reason} -> {:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
defp wait_for_server, do: wait_for_server(50)
defp wait_for_server(0), do: raise("Playwright server did not start")
defp wait_for_server(attempts) do
case :gen_tcp.connect(~c"127.0.0.1", @port, [], 100) do
{:ok, sock} ->
:gen_tcp.close(sock)
:ok
{:error, _} ->
Process.sleep(100)
wait_for_server(attempts - 1)
end
end
defp maybe_prepend_login(script, %{userId: _}) do
"""
await page.goto('/dev/login_as/' + context.userId, { waitUntil: 'load' });
#{script}
"""
end
defp maybe_prepend_login(script, _context), do: script
defp find_node_path do
# First check local node_modules in integration/playwright
local_path = Path.expand("../integration/playwright/node_modules", __DIR__)
if File.exists?(Path.join(local_path, "playwright")) do
local_path
else
find_global_node_path()
end
end
defp find_global_node_path do
case System.cmd("npm", ["root", "-g"], stderr_to_stdout: true) do
{global_path, 0} ->
global_path = String.trim(global_path)
if File.exists?(Path.join(global_path, "playwright")) do
global_path
else
find_npx_playwright_path()
end
_ ->
find_npx_playwright_path()
end
end
defp find_npx_playwright_path do
npm_cache = System.get_env("npm_config_cache") || Path.expand("~/.npm")
npx_dir = Path.join(npm_cache, "_npx")
if File.dir?(npx_dir) do
npx_dir
|> File.ls!()
|> Enum.find_value(fn subdir ->
node_modules = Path.join([npx_dir, subdir, "node_modules"])
playwright_path = Path.join(node_modules, "playwright")
if File.exists?(playwright_path), do: node_modules
end)
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment