Requires Elixir 1.19+, OTP 28+. Use mix wallabidi.install to download the browsers the drivers need (Chrome for Testing, Lightpanda, and the chromium-bidi Node deps) into .browsers/, or point the WALLABIDI_*_PATH env vars at existing binaries.
Installation
def deps do
[{:wallabidi, "~> 0.4.0-rc", runtime: false, only: :test}]
end# test/test_helper.exs
{:ok, _} = Application.ensure_all_started(:wallabidi)How browsers are managed
Wallabidi launches browsers directly — no chromedriver, Selenium server, or Docker container in the loop. mix wallabidi.install downloads everything the drivers need (Chrome for Testing, Lightpanda, and the chromium-bidi Node deps) into a single project-local .browsers/ directory:
$ MIX_ENV=test mix wallabidi.install # Chrome + Lightpanda + chromium-bidi → .browsers/
$ mix test
MIX_ENV=testis required when wallabidi is in yourdepsasonly: :test(the typical setup) — Mix only loads the task module in environments where wallabidi compiles. Plainmix wallabidi.installraisestask could not be found, and the task won't appear inmix helpeither (that runs in:dev). Run it asMIX_ENV=test mix wallabidi.install(likewiseMIX_ENV=test mix help wallabidi.installto see its docs).
Both browsers land in version-stamped subdirectories so multiple
versions coexist, and the resolved binary paths are recorded in
.browsers/PATHS:
.browsers/
PATHS # CHROME=… and LIGHTPANDA=…
chrome/mac_arm-149.0.7827.54/…
lightpanda/aarch64-macos-fork-2026-05-30/lightpanda-…Chrome
If a Chrome/Chromium (google-chrome, chromium, or chromium-browser) is on your PATH, Wallabidi launches it directly via CDP. mix wallabidi.install prefers a pre-installed browser: if it finds one on PATH it records that and skips the Chrome for Testing download (logging which binary it used); otherwise it downloads a pinned Chrome for Testing into .browsers/. Override the binary path with WALLABIDI_CHROME_PATH if Chrome lives somewhere unusual:
WALLABIDI_CHROME_PATH=/usr/bin/google-chrome-stable mix test
arm64 Linux: Chrome for Testing has no arm64-Linux build, so
mix wallabidi.installcannot download it there. Install a distro Chromium first (apt-get install -y chromium, or your distro'schromium/chromium-browserpackage) — wallabidi picks it up off PATH. If none is present on arm64 Linux the install errors out with this guidance rather than downloading an unusable binary.
When Chrome runs as a service in a Docker Compose stack, point Wallabidi at it with WALLABIDI_CHROME_URL (see Remote Chrome below).
Lightpanda
The Lightpanda binary is provided by the lightpanda dependency (the release tag is baked into that dep — bump it to upgrade). To use the Lightpanda driver, add the dep to your own project — it is not pulled in transitively:
# mix.exs
{:lightpanda, "~> 0.3", only: :test}mix wallabidi.install (and mix wallabidi.install.lightpanda) then downloads the binary into .browsers/lightpanda/ alongside Chrome. Without the dep the Lightpanda install task is a no-op (it prints Skipping Lightpanda (the lightpanda dep is not available)) and the Lightpanda driver can't start. Override the binary path with WALLABIDI_LIGHTPANDA_PATH for Docker/CI images that already ship Lightpanda:
WALLABIDI_LIGHTPANDA_PATH=/opt/lightpanda/lightpanda mix test
If you don't run mix wallabidi.install, the lightpanda package falls back to downloading into _build/ on first use.
Remote Chrome (CI / Docker)
When Chrome runs as a service in your Docker Compose stack, point Wallabidi at it:
# .env or CI config — just the host:port, wallabidi handles the rest
WALLABIDI_CHROME_URL=chrome:9222
Wallabidi auto-discovers the WebSocket URL via /json/version. Full ws:// URLs also work for backward compat.
CI (GitHub Actions)
steps:
- uses: actions/checkout@v6
- uses: erlef/setup-beam@v1
with:
otp-version: 28.x
elixir-version: 1.19.x
- uses: actions/setup-node@v4
with:
node-version: 20
- run: mix deps.get
- run: MIX_ENV=test mix wallabidi.install # Chrome + Lightpanda + chromium-bidi → .browsers/
- run: mix testmix wallabidi.install uses npx @puppeteer/browsers install to download
a pinned Chrome for Testing binary, plus the Lightpanda binary, into
.browsers/. To install just one browser, use mix wallabidi.install.chrome
or mix wallabidi.install.lightpanda. Cache the directory for faster
subsequent runs:
- uses: actions/cache@v5
with:
path: .browsers
key: ${{ runner.os }}-browsers-${{ hashFiles('.browsers/PATHS') }}
restore-keys: ${{ runner.os }}-browsers-Running the suite across drivers
A plain mix test uses the default driver ladder:
each test runs on the cheapest driver that supports it (untagged →
LiveView, @tag :headless → Lightpanda, @tag :browser → Chrome). That's
the right default for local dev and a fast CI lane.
For thorough CI you often want the opposite: run every feature on every
browser capable of running it — e.g. exercise a @tag :headless test on
both Lightpanda and Chrome. ExUnit applies one driver per run, so this is
a small matrix of separate runs, not a single invocation. The mechanism:
@tag :headless/@tag :browserdeclare each feature's minimum capability. Excluding the tiers a driver can't satisfy selects exactly what it can run — LiveView drops both, Lightpanda drops:browser, Chrome runs everything.WALLABIDI_DRIVER=<driver>pins the whole run to that driver (disabling the cheapest-driver ladder), so every selected test executes on it.
| Lane | Command |
|---|---|
| Features on LiveView | WALLABIDI_DRIVER=live_view mix test --exclude headless --exclude browser |
| Features on Lightpanda | WALLABIDI_DRIVER=lightpanda mix test --exclude browser |
| Features on Chrome (CDP) | WALLABIDI_DRIVER=chrome_cdp mix test |
| Features on Chrome (BiDi) | WALLABIDI_DRIVER=chrome mix test |
Use plain
--exclude, not--only feature. ExUnit's--only/--includeoverrides excludes for any matching test, so--only feature --exclude browserwould still run the browser features. Capability filtering only works through excludes.
These lanes also run your plain (non-feature) unit tests — which is
harmless (they start no browser) and gives untagged features coverage on
every driver. If you'd rather keep a separate, browser-free fast lane,
add one with mix test --exclude feature (the feature macro tags every
use Wallabidi.Feature test :feature):
| Lane | Command |
|---|---|
| Unit only (no browser) | mix test --exclude feature |
As a GitHub Actions matrix — a fast unit lane plus one parallel job per browser:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: erlef/setup-beam@v1
with: { otp-version: 28.x, elixir-version: 1.19.x }
- run: mix deps.get
- run: mix test --exclude feature
features:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- driver: live_view
exclude: "--exclude headless --exclude browser"
- driver: lightpanda
exclude: "--exclude browser"
- driver: chrome_cdp
exclude: ""
steps:
- uses: actions/checkout@v6
- uses: erlef/setup-beam@v1
with: { otp-version: 28.x, elixir-version: 1.19.x }
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: mix deps.get
- run: MIX_ENV=test mix wallabidi.install
- run: mix test ${{ matrix.exclude }}
env:
WALLABIDI_DRIVER: ${{ matrix.driver }}Environment variable overrides
For Docker-based CI or remote browsers:
| Variable | Purpose | Example |
|---|---|---|
WALLABIDI_CHROME_URL | Connect to remote Chrome (CDP) | chrome:9222 |
WALLABIDI_CHROME_PATH | Local Chrome binary override | /usr/bin/google-chrome |
WALLABIDI_LIGHTPANDA_PATH | Local Lightpanda binary override | /opt/lightpanda/lightpanda |
If you have Chrome pre-installed on the runner (e.g. GitHub Actions' built-in
Chrome), set WALLABIDI_CHROME_PATH and skip mix wallabidi.install:
- run: mix test
env:
WALLABIDI_CHROME_PATH: /usr/bin/google-chrome-stablePhoenix
The default :live_view driver renders in-process and needs no HTTP
server. The moment any test uses a browser driver — @tag :headless /
@tag :browser, or a browser :driver — the endpoint must run a server
during tests and base_url must point at the port it actually binds:
# config/test.exs
config :your_app, YourAppWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
url: [host: "localhost", port: 4002], # see the port note below
server: true
# test/test_helper.exs
Application.put_env(:wallabidi, :base_url, YourAppWeb.Endpoint.url())Make
Endpoint.url()match the bound port.Endpoint.url()reflects the:urlconfig (used for link generation), not thehttp:listener. A generated Phoenix app bindshttp:on4002but leaves:urldefaulting to4000, sobase_urlends up pointing at4000and everyvisit/2lands on a "connection refused" browser error page. A trivial smoke assertion likeassert_has(css("body"))will even pass against that error page. Set:urlto the same port ashttp:(above) so the two agree.
Watch out for
config/runtime.exs. Phoenix 1.8's generatedruntime.exssets the endpointhttp:port fromPORT(default4000) outside the:prodblock, andruntime.exsloads afterconfig/test.exs— so it silently overrides your test port back to4000while:urlstays put, reproducing the connection-refused symptom above. Guard it so the test env keeps the port fromconfig/test.exs:# config/runtime.exs if config_env() != :test do config :your_app, YourAppWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))] end
Build your JS assets for browser tests
A real-browser driver loads your app over HTTP and runs your JavaScript
— the LiveSocket your app.js creates is what connects the WebSocket and
drives every LiveView update (phx-* events, stream_insert, async
assigns). In :dev Phoenix's watchers rebuild assets live, so this Just
Works when you load a page by hand. Under mix test nothing builds
them — the generated test alias builds the database but not assets —
so the LiveView client never boots and dynamic content never appears
(while static, mount-rendered content does, which makes the failure
baffling).
Build assets as part of the test run by adding assets.build to your
test alias:
# mix.exs
defp aliases do
[
test: ["ecto.create --quiet", "ecto.migrate --quiet", "assets.build", "test"]
# ...
]
endor run MIX_ENV=test mix assets.build before the suite in CI. If you
forget, wallabidi logs a warning the first time it visits a LiveView page
whose window.liveSocket never initialized, pointing back here.
Test isolation
Browser tests need sandbox access propagated to every server-side process the
browser triggers (Ecto, Mimic, Mox, Cachex, FunWithFlags). See the
Test Isolation guide for the full sandbox_case /
sandbox_shim setup.