Concurrent browser testing for Elixir. Write your tests once — they run on the fastest driver that supports them.
What that means in practice:
- Real multi-threading with Chrome via CDP (and aspirationally BiDi) — no chromedriver in the loop. CDP performs better today; BiDi is the future-proof path and tracks the W3C protocol's evolution.
- Multi-threading with Lightpanda — a headless JS-capable browser that's fast enough to run nearly as quickly as the LiveView driver. Lightpanda is a practical default for full functional test suites.
- LiveView driver for tests that don't need a browser at all — renders pages in-process via
Phoenix.ConnTest.
Wallabidi is a fork of Wallaby with these four drivers, automatic LiveView-aware waiting, and a public API close to Wallaby's for easy migration.
| Driver | Speed | What it does | When to use |
|---|---|---|---|
| LiveView | ~0ms/test | Renders pages in-process via Phoenix.ConnTest. No browser. | Default for local dev — instant feedback |
| Lightpanda | ~50ms/test | Headless JS-capable browser via CDP. No CSS rendering. | Fast path for full functional suites — nearly LiveView speed |
| Chrome (CDP) | ~200ms/test | Full browser via Chrome DevTools Protocol. Real multi-threading via Chrome's per-target threads. | Full fidelity (CSS, screenshots, mouse). Best concurrent throughput today. |
| Chrome (BiDi) | ~600ms/test | Full browser via WebDriver BiDi (chromium-bidi → Chrome). Cross-engine portable. | Future-proof choice as BiDi matures; aspirationally replaces CDP. |
Tests declare their minimum requirement with tags:
# Runs on LiveView driver (fastest)
feature "create todo", %{session: session} do
session |> visit("/todos") |> fill_in(text_field("Title"), with: "Buy milk") |> ...
end
# Needs a headless browser (JS execution, cookies)
@tag :headless
feature "stores preference in cookie", %{session: session} do
session |> visit("/settings") |> execute_script("document.cookie = 'theme=dark'", [])
end
# Needs a full browser (screenshots, CSS visibility, mouse events)
@tag :browser
feature "uploads a file", %{session: session} do
session |> visit("/upload") |> attach_file(file_field("Photo"), path: "test/fixtures/photo.jpg")
endEach test runs on the cheapest driver that supports it. No env vars, no aliases — just mix test.
Each driver scales differently with --max-cases. The values below come from running the perf_bench LV scenario suite on a 16-thread Mac laptop (M-series). perf_bench is a separate harness containing 136 LiveView-focused scenarios — happy paths only, no waiting-for-absence tests — so it's a better fit for cross-driver measurements than the wallabidi integration suite, which contains plenty of error-case waits.
Wall time in seconds for the perf_bench LiveView scenario suite (136 tests) at each --max-cases:
| Driver | mc1 | mc2 | mc4 | mc8 | mc16 |
|---|---|---|---|---|---|
| LiveView | 15s | 9s | 6s | 4s | 4s |
| Lightpanda | 43s | 22s | 12s | 8s | 8s |
| CDP (Chrome) | 68s | 52s | 48s | 51s | 52s |
| BiDi (Chrome) | 486s | 100s | 71s | 68s | 259s ⚠ (2 flakes) |
| Wallaby (chromedriver) | 218s | 122s | 80s | 69s ⚠ (4 flakes) | 70s ⚠ (5 flakes) |
⚠ flag = flaky failures at this concurrency. Chrome BiDi's mc=16 trips chromium-bidi's BiDi Mapper contention; Wallaby's mc=8+ trips chromedriver session-creation timeouts.
Recommended --max-cases per driver:
| Driver | Recommended | Why |
|---|---|---|
| BiDi | 8 |
chromium-bidi's BiDi Mapper is single-threaded JS in one Chrome tab. mc=8 captures the scaling win; mc=16 trips structural flakes. |
| CDP | 4 |
CDP's flat-session protocol multiplexes parallel work across Chrome's per-target threads. mc=4 is the sweet spot; past that you save no wallclock. |
| Lightpanda | 8–16 |
In-process WS, scales linearly to mc=8 then plateaus at LP's --cdp-max-connections limit. |
| LiveView | 8–16 |
No external process; just BEAM. Use as much concurrency as ExUnit allows. |
When to pick which driver in CI:
- Default: let wallabidi route each test to the cheapest driver that supports it. Most LiveView-app tests run on the LiveView driver and are nearly free.
- JS-heavy app: Lightpanda at mc=8 — fastest real headless option, within 2× of LiveView at scale.
- Need full browser fidelity (CSS, screenshots, mouse events): CDP at mc=4.
- Cross-browser portability or BiDi spec features: BiDi at mc=8. Slower than CDP today because the BiDi protocol serializes through a single Mapper per Chrome; will improve as chromium-bidi or successor implementations add parallel mapping.
Wallaby is excellent. We forked because the changes we wanted were too invasive to contribute upstream — replacing the entire transport layer, removing Selenium, dropping four HTTP dependencies, and changing the default click mechanism. These aren't bug fixes; they're architectural decisions that would break backward compatibility for Wallaby's existing users.
We also wanted features that only make sense with BiDi: automatic LiveView-aware waiting on every interaction, request interception, event-driven log capture. Building these on top of Wallaby's HTTP polling model would have been the wrong abstraction.
If you're starting a new project or are willing to do a find-and-replace, Wallabidi gives you a simpler dependency tree, automatic LiveView-aware waiting on every interaction, and access to modern browser APIs. If you need Selenium (the Java server) support, stay with Wallaby. Firefox support via GeckoDriver is architecturally possible (it also speaks BiDi) but not yet implemented.
Protocol: Browser communication goes over WebSocket — Chrome via CDP or BiDi, Lightpanda via CDP — never HTTP polling. This means event-driven log capture, lower latency, and access to features impossible with request-response HTTP.
LiveView-aware by default: Every interaction automatically waits for the right thing — no manual sleeps or retry loops needed:
visit/2waits for the LiveSocket to connect before returning.click/2inspects the target element's bindings (phx-click,data-phx-link, plainhref) and classifies the interaction as patch, navigate, or full-page in the same round-trip as the click itself. It then awaits the corresponding DOM patch, page load, or LiveView reconnection automatically.fill_in/3onphx-changeinputs fuses silent-clear + set-value + drain-patches into one round-trip — the call returns only once the server has finished processing the final phx-change.assert_has/2uses an event-drivenawait_selectorthat hooks into LiveView'sonPatchEndcallback and aMutationObserver— it fires the next-match check exactly when the DOM changes, never polls.has_text?/2,has_value?/2route through the same event-driven pattern: a single Promise inside the browser resolves the moment the predicate matches, replacing Elixir-side polling loops.
All of this is installed via injected JavaScript — no changes to your app.js or LiveSocket config are needed.
Architecture: A single opcode interpreter (W.run) on the page side handles every Elixir → browser call. The Elixir side ships opcode lists like [["query","css",".btn"],["classify_first","click"],["click_first"]], never raw JS function bodies. Compound operations (click_aware, fill_in, has_text) fuse multiple steps into a single Promise so each logical operation is one network round-trip.
Lazy elements: Most Browser APIs that find then immediately operate (Browser.text, attr, fill_in, click, has_text?...) skip the V8-object-id ref-fetch that Wallaby would do — the element op re-resolves the query inline on the page. Saves one round-trip per element op without changing semantics.
New features:
await_patch/2— Wait for the next LiveView DOM patch. Useful for server-pushed updates that aren't triggered by a browser interaction.
Four drivers: LiveView (in-process, no browser), Lightpanda (headless CDP), Chrome CDP (full browser, direct DevTools Protocol), Chrome BiDi (full browser, W3C WebDriver BiDi via chromium-bidi). Tests declare their minimum requirement with @tag :headless or @tag :browser.
Removed:
- Selenium driver — replaced with native BiDi + CDP
- HTTPoison / Hackney dependencies — replaced with Mint
create_session_fn/end_session_fnoptions
Simplified:
- Direct CDP/BiDi transport — no chromedriver process to manage
- Event-driven JS error detection (no HTTP polling per command)
- W3C capabilities format (
goog:chromeOptions)
- Replace the dependency:
# mix.exs
{:wallabidi, "~> 0.2", runtime: false, only: :test}- Find and replace in your project:
| Wallaby | Wallabidi |
|---|---|
Wallaby. |
Wallabidi. |
:wallaby |
:wallabidi |
config :wallaby, |
config :wallabidi, |
- Remove if present:
# No longer needed
config :wallaby, driver: Wallaby.Chrome
config :wallaby, hackney_options: [...]- That's it. The
Browser,Query,Element,Feature, andDSLAPIs are the same.
Requires Elixir 1.19+, OTP 28+, and Chrome (or Chromium). Use mix wallabidi.install to download a pinned Chrome for Testing build, or set WALLABIDI_CHROME_PATH to your existing Chrome binary.
def deps do
[{:wallabidi, "~> 0.2", runtime: false, only: :test}]
end# test/test_helper.exs
{:ok, _} = Application.ensure_all_started(:wallabidi)Wallabidi launches Chrome directly — no chromedriver, Selenium server, or Docker container in the loop. There are two modes:
If Chrome is on your PATH or has been installed by mix wallabidi.install, Wallabidi launches it directly via CDP.
$ mix wallabidi.install # downloads Chrome for Testing into .browsers/
$ mix test
Override the binary path with WALLABIDI_CHROME_PATH if Chrome lives somewhere unusual:
WALLABIDI_CHROME_PATH=/usr/bin/google-chrome-stable mix testWhen 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:9222Wallabidi auto-discovers the WebSocket URL via /json/version. Full ws:// URLs also work for backward compat.
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 wallabidi.install # downloads Chrome for Testing
- run: mix testmix wallabidi.install uses npx @puppeteer/browsers install to download
a pinned Chrome for Testing binary into .browsers/. Cache this directory
for faster subsequent runs:
- uses: actions/cache@v5
with:
path: .browsers
key: ${{ runner.os }}-browsers-${{ hashFiles('.browsers/PATHS') }}
restore-keys: ${{ runner.os }}-browsers-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 |
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-stable# config/test.exs
config :your_app, YourAppWeb.Endpoint, server: true
# test/test_helper.exs
Application.put_env(:wallabidi, :base_url, YourAppWeb.Endpoint.url)Browser tests need sandbox access propagated to every server-side process the browser triggers (Plug requests, LiveView mounts, async tasks). Wallabidi integrates with sandbox_case and sandbox_shim to handle this automatically.
sandbox_case manages checkout/checkin of all sandbox adapters (Ecto, Cachex, FunWithFlags, Mimic, Mox) from a single config. sandbox_shim provides compile-time macros that wire the sandbox plugs and hooks into your endpoint and LiveViews — emitting nothing in production.
# mix.exs
{:sandbox_shim, "~> 0.1"}, # all envs (compile-time only)
{:sandbox_case, "~> 0.3", only: :test}, # test only
{:wallabidi, "~> 0.2", only: :test, runtime: false}, # test only# config/test.exs
config :sandbox_case,
otp_app: :your_app,
sandbox: [
ecto: true,
cachex: [:my_cache], # optional
fun_with_flags: true, # optional
mimic: true, # auto-discovers Mimic.copy'd modules
mox: [MyApp.MockWeather] # optional
]# lib/your_app_web/endpoint.ex
import SandboxShim
sandbox_plugs()
sandbox_socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]]# lib/your_app_web.ex
def live_view do
quote do
use Phoenix.LiveView
import SandboxShim
sandbox_on_mount()
# auth hooks after
end
end# test/test_helper.exs
SandboxCase.Sandbox.setup()
{:ok, _} = Application.ensure_all_started(:wallabidi)With use Wallabidi.Feature, sandbox checkout/checkin is automatic — no manual Ecto.Adapters.SQL.Sandbox.checkout calls needed.
It's easiest to add Wallabidi to your test suite by using the Wallabidi.Feature module.
defmodule MyApp.Features.TodoTest do
use ExUnit.Case, async: true
use Wallabidi.Feature
feature "users can create todos", %{session: session} do
session
|> visit("/todos")
|> fill_in(Query.text_field("New Todo"), with: "Write a test")
|> click(Query.button("Save"))
|> assert_has(Query.css(".todo", text: "Write a test"))
end
endBecause Wallabidi manages multiple browsers for you, it's possible to test several users interacting with a page simultaneously.
@sessions 2
feature "users can chat", %{sessions: [user1, user2]} do
user1
|> visit("/chat")
|> fill_in(text_field("Message"), with: "Hello!")
|> click(button("Send"))
user2
|> visit("/chat")
|> assert_has(css(".message", text: "Hello!"))
endWallabidi's API is built around two concepts: Queries and Actions.
Queries allow us to declaratively describe the elements that we would like to interact with and Actions allow us to use those queries to interact with the DOM.
Let's say that our HTML looks like this:
<ul class="users">
<li class="user">
<span class="user-name">Ada</span>
</li>
<li class="user">
<span class="user-name">Grace</span>
</li>
<li class="user">
<span class="user-name">Alan</span>
</li>
</ul>If we wanted to interact with all of the users then we could write a query like so css(".user", count: 3).
If we only wanted to interact with a specific user then we could write a query like this css(".user-name", count: 1, text: "Ada"). Now we can use those queries with some actions:
session
|> find(css(".user", count: 3))
|> List.first()
|> assert_has(css(".user-name", count: 1, text: "Ada"))There are several queries for common HTML elements defined in the Wallabidi.Query module: css, text_field, button, link, option, radio_button, and more. All actions accept a query. Actions will block until the query is either satisfied or the action times out. Blocking reduces race conditions when elements are added or removed dynamically.
We can navigate directly to pages with visit:
visit(session, "/page.html")
visit(session, user_path(Endpoint, :index, 17))It's also possible to click links directly:
click(session, link("Page 1"))We can find a specific element or list of elements with find:
@user_form css(".user-form")
@name_field text_field("Name")
@email_field text_field("Email")
@save_button button("Save")
find(page, @user_form, fn(form) ->
form
|> fill_in(@name_field, with: "Chris")
|> fill_in(@email_field, with: "c@keathley.io")
|> click(@save_button)
end)Passing a callback to find will return the parent which makes it easy to chain find with other actions:
page
|> find(css(".users"), & assert has?(&1, css(".user", count: 3)))
|> click(link("Next Page"))Without the callback find returns the element. This provides a way to scope all future actions within an element.
page
|> find(css(".user-form"))
|> fill_in(text_field("Name"), with: "Chris")
|> fill_in(text_field("Email"), with: "c@keathley.io")
|> click(button("Save"))There are a few ways to interact with form elements on a page:
fill_in(session, text_field("First Name"), with: "Chris")
clear(session, text_field("last_name"))
click(session, option("Some option"))
click(session, radio_button("My Fancy Radio Button"))
click(session, button("Some Button"))If you need to send specific keys to an element, you can do that with send_keys:
send_keys(session, ["Example", "Text", :enter])Wallabidi provides custom assertions to make writing tests easier:
assert_has(session, css(".signup-form"))
refute_has(session, css(".alert"))
has?(session, css(".user-edit-modal", visible: false))assert_has and refute_has both take a parent element as their first argument. They return that parent, making it easy to chain them together with other actions.
session
|> assert_has(css(".signup-form"))
|> fill_in(text_field("Email"), with: "c@keathley.io")
|> click(button("Sign up"))
|> refute_has(css(".error"))
|> assert_has(css(".alert", text: "Welcome!"))You can set the default window size by passing in the window_size option into Wallabidi.start_session/1.
Wallabidi.start_session(window_size: [width: 1280, height: 720])You can also resize the window and get the current window size during the test.
resize_window(session, 100, 100)
window_size(session)It's possible to take screenshots:
take_screenshot(session)All screenshots are saved to a screenshots directory in the directory that the tests were run in. You can customize this with configuration (see below).
To automatically take screenshots on failure when using the Wallabidi.Feature.feature/3 macro:
# config/test.exs
config :wallabidi, screenshot_on_failure: trueWallabidi captures both JavaScript logs and errors. Any uncaught exceptions in JavaScript will be re-thrown in Elixir. This can be disabled by specifying js_errors: false in your Wallabidi config.
JavaScript logs are written to :stdio by default. This can be changed to any IO device by setting the :js_logger option in your Wallabidi config. For instance if you want to write all JavaScript console logs to a file you could do something like this:
{:ok, file} = File.open("browser_logs.log", [:write])
Application.put_env(:wallabidi, :js_logger, file)Logging can be disabled by setting :js_logger to nil.
Wallabidi provides several ways to interact with JavaScript dialogs such as window.alert, window.confirm and window.prompt.
- For
window.alertuseaccept_alert/2 - For
window.confirmuseaccept_confirm/2ordismiss_confirm/2 - For
window.promptuseaccept_prompt/2-3ordismiss_prompt/2
All of these take a function as last parameter, which must include the necessary interactions to trigger the dialog. For example:
alert_message = accept_alert session, fn(session) ->
click(session, link("Trigger alert"))
endTo emulate user input for a prompt, accept_prompt takes an optional parameter:
prompt_message = accept_prompt session, [with: "User input"], fn(session) ->
click(session, link("Trigger prompt"))
endWait for the page to become idle. Checks two signals: no pending HTTP requests for the idle period, and no LiveView phx-*-loading classes present.
You don't need settle after click, fill_in, or visit — those already wait automatically. Use settle for updates triggered by something other than a direct interaction:
# PubSub broadcast — no browser interaction triggered it
Phoenix.PubSub.broadcast(MyApp.PubSub, "updates", :refresh)
session
|> settle()
|> assert_has(Query.css(".updated"))Mock HTTP responses in the browser:
session
|> intercept_request("/api/users", %{
status: 200,
headers: [%{name: "content-type", value: "application/json"}],
body: ~s({"users": []})
})
|> visit("/page")Stream browser console output:
session
|> on_console(fn level, message ->
IO.puts("[#{level}] #{message}")
end)Minimal — just tell Wallabidi about your app:
# config/test.exs
config :wallabidi,
otp_app: :your_app,
endpoint: YourAppWeb.EndpointThe default driver is :chrome. To use LiveView for fast local testing:
config :wallabidi,
otp_app: :your_app,
endpoint: YourAppWeb.Endpoint,
driver: :live_viewAll options with defaults:
config :wallabidi,
otp_app: :your_app, # required for Ecto sandbox
endpoint: YourAppWeb.Endpoint, # required for LiveView driver
driver: :chrome, # :live_view | :lightpanda | :chrome
max_wait_time: 5_000, # ms to wait for elements
js_errors: true, # re-raise JS errors in Elixir
js_logger: :stdio, # IO device for console logs (nil to disable)
screenshot_on_failure: false,
screenshot_dir: "screenshots"Wallabidi is built on the foundation of Wallaby, created by Mitchell Hanberg and contributors. The Browser, Query, Element, Feature, and DSL APIs are theirs. Wallabidi adds the BiDi transport layer, new DX features, and removes the Selenium/HTTP legacy code.
Licensed under MIT, same as Wallaby.
mix test # unit tests
mix test.live_view # LiveView driver integration tests
mix test.lightpanda # Lightpanda CDP integration tests
mix test.chrome # Chrome CDP integration tests
mix test.chrome.bidi # Chrome BiDi (chromium-bidi) integration tests
mix test.all # all of the above
mix test.browsers --browsers chrome # run ALL tests on a specific browserThe LiveView and Lightpanda tests require no external dependencies — Lightpanda's binary is installed automatically via mix lightpanda.install. Chrome tests need a local Chrome binary (use mix wallabidi.install to download one) or WALLABIDI_CHROME_URL pointing at a remote Chrome.