Wallabidi

License

Concurrent browser testing for Elixir. Write your tests once — they run on the fastest driver that supports them.

What that means in practice:

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.

Drivers

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")
end

Each test runs on the cheapest driver that supports it. No env vars, no aliases — just mix test.

Concurrency and performance

Each driver scales differently with --max-cases. The values below come from running the integration suite end-to-end on a 16-thread Mac laptop (M-series, all suites passing at the listed concurrency).

per-test wall time vs max-cases

Wall time in seconds for the full integration suite at each --max-cases:

Driver mc1 mc2 mc4 mc8 mc16 Tests
BiDi (Chrome) 917s 690s 285
CDP (Chrome) 421s 240s 175s 148s ⚠ 144s ⚠ 289
Lightpanda 140s 84s 49s 34s 30s 153
LiveView 122s 78s 42s 28s 25s 124

⚠ = passes mostly but introduces 1–2 flaky failures from concurrency contention.

Recommended --max-cases per driver:

Driver Recommended Why
BiDi2 chromium-bidi's BiDi Mapper is single-threaded JS in one Chrome tab. Each pool slot adds another Chrome+Mapper, so concurrency = pool size. Benchmarked up to 2; higher is possible.
CDP4 CDP's flat-session protocol multiplexes parallel work across Chrome's per-target threads. mc4 is the sweet spot — beyond it you save ~30s and pick up flakes.
Lightpanda16 In-process, scales near-linearly with BEAM concurrency.
LiveView16 No external process; just BEAM. Use as much concurrency as ExUnit allows.

When to pick which driver in CI:

Why fork?

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.

What's different from Wallaby?

Protocol: All browser communication uses WebDriver BiDi over WebSocket instead of 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:

All of this is installed via injected JavaScript — no changes to your app.js or LiveSocket config are needed.

New features:

Three drivers: LiveView (in-process, no browser), Lightpanda (headless CDP), Chrome (full BiDi). Tests declare their minimum requirement with @tag :headless or @tag :browser.

Removed:

Simplified:

Migrating from Wallaby

  1. Replace the dependency:
# mix.exs
{:wallabidi, "~> 0.2", runtime: false, only: :test}
  1. Find and replace in your project:
Wallaby Wallabidi
Wallaby.Wallabidi.
:wallaby:wallabidi
config :wallaby,config :wallabidi,
  1. Remove if present:
# No longer needed
config :wallaby, driver: Wallaby.Chrome
config :wallaby, hackney_options: [...]
  1. That's it. The Browser, Query, Element, Feature, and DSL APIs are the same.

Setup

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.

Installation

def deps do
  [{:wallabidi, "~> 0.2", runtime: false, only: :test}]
end
# test/test_helper.exs
{:ok, _} = Application.ensure_all_started(:wallabidi)

How Chrome is managed

Wallabidi launches Chrome directly — no chromedriver, Selenium server, or Docker container in the loop. There are two modes:

1. Local Chrome (default)

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 test

2. 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 wallabidi.install   # downloads Chrome for Testing
- run: mix test

mix 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-

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

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

Phoenix

# config/test.exs
config :your_app, YourAppWeb.Endpoint, server: true

# test/test_helper.exs
Application.put_env(:wallabidi, :base_url, YourAppWeb.Endpoint.url)

Test isolation (Ecto, Mimic, Mox, Cachex, FunWithFlags)

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.

Usage

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
end

Because 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!"))
end

API

Queries and actions

Wallabidi'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.

Navigation

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"))

Finding

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"))

Interacting with forms

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])

Assertions

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!"))

Window size

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)

Screenshots

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: true

JavaScript logging and errors

Wallabidi 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.

Interacting with dialogs

Wallabidi provides several ways to interact with JavaScript dialogs such as window.alert, window.confirm and window.prompt.

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"))
end

To 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"))
end

settle

Wait 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"))

intercept_request

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")

on_console

Stream browser console output:

session
|> on_console(fn level, message ->
  IO.puts("[#{level}] #{message}")
end)

Configuration

Minimal — just tell Wallabidi about your app:

# config/test.exs
config :wallabidi,
  otp_app: :your_app,
  endpoint: YourAppWeb.Endpoint

The default driver is :chrome. To use LiveView for fast local testing:

config :wallabidi,
  otp_app: :your_app,
  endpoint: YourAppWeb.Endpoint,
  driver: :live_view

All 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"

Credits

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.

Contributing

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 browser

The 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.