Skip to content

Instantly share code, notes, and snippets.

@ityonemo
Created March 16, 2026 20:27
Show Gist options
  • Select an option

  • Save ityonemo/1a4d5ba9d15a9c89bd2ee4acf76fccb9 to your computer and use it in GitHub Desktop.

Select an option

Save ityonemo/1a4d5ba9d15a9c89bd2ee4acf76fccb9 to your computer and use it in GitHub Desktop.
Elixir - Async playwright "integration" testing
defmodule Integration.Playwright do
@moduledoc """
Runs Playwright scripts with sandbox metadata injection using MuonTrap.
MuonTrap ensures Node/Playwright processes are killed when tests exit.
## Usage
Include `userId` in the context to automatically authenticate before running the script:
Playwright.run!(~S\"""
await page.goto('/notes');
await expect(page.locator('h1')).toContainText('Notes');
\""", %{userId: user.id})
Additional context values are available in scripts as `context.keyName`:
Playwright.run!(~S\"""
await page.goto('/notes/' + context.noteId);
await expect(page.locator('.note-title')).toContainText(context.title);
\""", %{userId: user.id, noteId: note.id, title: note.title})
"""
@base_url "http://127.0.0.1:4002"
@runner_path Path.expand("../integration/playwright/runner.js", __DIR__)
@timeout 30_000
defp wait_for_server(attempts \\ 50) do
case :gen_tcp.connect(~c"127.0.0.1", 4002, [], 100) do
{:ok, sock} ->
:gen_tcp.close(sock)
:ok
{:error, _} when attempts > 0 ->
Process.sleep(100)
wait_for_server(attempts - 1)
{:error, reason} ->
raise "Server not available on port 4002: #{inspect(reason)}"
end
end
# Find NODE_PATH for playwright (checks npx cache if not in global modules)
defp 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
@doc """
Runs a Playwright script with the given context.
The script has access to:
- `page` - The Playwright Page object
- `context` - The context map passed from Elixir
- `expect` - Playwright's expect function for assertions
If `userId` is present in the context, the script will automatically
navigate to `/login_as/:id` first to authenticate as that user.
Raises on failure with the error message from Playwright.
"""
def run!(script, context \\ %{}) do
script = maybe_prepend_login(script, context)
# Ensure server is ready before running Playwright
wait_for_server()
sandbox_metadata = Process.get(:sandbox_metadata)
payload = %{
script: script,
context: context,
baseUrl: @base_url,
sandboxMetadata: sandbox_metadata
}
json_payload = Jason.encode!(payload)
# Write payload to temp file (MuonTrap doesn't support stdin)
tmp_path = Path.join(System.tmp_dir!(), "playwright_#{:erlang.unique_integer([:positive])}.json")
File.write!(tmp_path, json_payload)
try do
env =
case node_path() do
nil -> []
path -> [{"NODE_PATH", path}]
end
{output, exit_code} =
MuonTrap.cmd("node", [@runner_path, tmp_path],
stderr_to_stdout: true,
env: env,
timeout: @timeout
)
case exit_code do
0 ->
:ok
_ ->
raise "Playwright script failed:\n#{output}"
end
after
File.rm(tmp_path)
end
end
defp maybe_prepend_login(script, %{userId: _}) do
"""
await page.goto('/dev/login_as/' + context.userId, { waitUntil: 'domcontentloaded' });
#{script}
"""
end
defp maybe_prepend_login(script, _context), do: script
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment