Created
April 21, 2026 22:25
-
-
Save ityonemo/57ba241986ea8b6f5a4bb73615eeefbb to your computer and use it in GitHub Desktop.
Simple Elixir Playwright integration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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