Wallabidi

License

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

Wallabidi is a fork of Wallaby with three test drivers, automatic LiveView-aware waiting, and a public API close to Wallaby's for easy migration.

Three 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 browser via CDP. No CSS rendering. CI fast path, JS-dependent tests
Chrome ~200ms/test Full browser via WebDriver BiDi. Full fidelity, screenshots, visual testing

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.

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 either Docker or a local ChromeDriver installation.

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 needs ChromeDriver + Chrome to run tests. There are three modes, tried in this order:

1. Remote (explicit remote_url)

When Chrome runs as a service in your Docker Compose stack (e.g. in a devcontainer), point Wallabidi at it:

# config/test.exs
config :wallabidi,
  chromedriver: [remote_url: "http://chrome:9515/"]

Example compose.yml:

services:
  app:
    # your Elixir app
    depends_on: [chrome]

  chrome:
    image: erseco/alpine-chromedriver:latest
    shm_size: 512m

No automatic container management — you control the lifecycle via Compose. Wallabidi polls the /status endpoint until the service is ready.

2. Local ChromeDriver

If ChromeDriver and Chrome are installed locally, Wallabidi uses them directly. This is the fastest mode (no Docker overhead) and how CI typically works (GitHub Actions has Chrome pre-installed).

$ brew install chromedriver  # macOS
$ mix test                   # Uses local chromedriver

Configure the binary paths if they're not in your PATH:

config :wallabidi,
  chromedriver: [
    path: "/path/to/chromedriver",
    binary: "/path/to/chrome"
  ]

3. Automatic Docker (fallback)

If no remote_url is configured and no local ChromeDriver is found, Wallabidi will automatically start a Docker container with ChromeDriver and Chromium. No configuration needed — just have Docker running.

$ mix test  # Just works. Docker container starts and stops automatically.

The container (erseco/alpine-chromedriver) is multi-arch (ARM64 + AMD64) and is cleaned up when your test suite finishes. The image is ~750MB (Chromium is large — but this is half the size of the Selenium Grid image). URLs are automatically rewritten so Chrome in the container can reach your local test server.

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",
  chromedriver: [
    headless: true,                # run Chrome headless
    path: "chromedriver",          # chromedriver binary path
    binary: "/path/to/chrome",     # Chrome binary path
    remote_url: "http://chrome:9515/"  # for Docker/remote Chrome
  ]

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 BiDi integration tests
mix test.chrome.lifecycle   # chromedriver startup tests (subprocess)
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 Docker (auto-detected) or a local ChromeDriver.