Cerberus

Hex.pm VersionHex DocsLicenseGitHub Actions Workflow Status

Cerberus hero artwork

Cerberus is an experimental Phoenix testing library with one API across:

Minimal API: You control your tests. Easily run single tests/describe blocks/entire modules in one or more browsers.

Performance Highlight

Live (non-browser) assertions are optimized for large pages:

Browser assertions/path checks use an in-browser wait loop as the fast path:

30-Second Start

# mix.exs
{:cerberus, "~> 0.1"}
import Cerberus
session()
|> visit("/live/counter")
|> click(~l"button:Increment"r)
|> assert_has(~l"Count: 1")

Tip

Start with session() for most scenarios. Move to session(:browser) when validating real browser behavior, keyboard/mouse APIs, or browser-only assertions. Use session(conn) when you need to continue from an existing Plug.Conn state. Use session(:chrome) / session(:firefox) when you want an explicit browser target. Project CI currently runs Chrome lanes only; run Firefox lanes explicitly when needed.

Progressive Examples

1. Static Text Assertions

session()
|> visit("/articles")
|> assert_has(~l"Articles")
|> refute_has(~l"500 Internal Server Error")

2. LiveView Interaction

session()
|> visit("/live/counter")
|> click(~l"button:Increment"r)
|> assert_has(~l"Count: 1")

3. Form + Path Assertions

session()
|> visit("/search")
|> fill_in("Search term", "Aragorn")
|> submit(~l"button:Run Search"r)
|> assert_path("/search/results", query: %{q: "Aragorn"})
|> assert_has(~l"Search query: Aragorn")

4. Scope + Navigation

session()
|> visit("/scoped")
|> within(~l"#secondary-panel"c, fn scoped ->
scoped
|> assert_has(~l"Status: secondary")
|> click(~l"link:Open"r)
end)
|> assert_path("/search")

within/3 expects locator input (for example, within(~l"#secondary-panel"c, fn s -> ... end)). In browser sessions, locator-based within/3 can switch root into same-origin iframes.

Scoped text assertions also support plain-string shorthand:

session()
|> visit("/scoped")
|> assert_has(~l"#secondary-panel"c, "Status: secondary")
|> refute_has(~l"#secondary-panel"c, "Status: primary")

Scoped assertion overloads use explicit scope and locator arguments:

Field-wrapper assertion pattern (Phoenix core_components-style wrappers):

session()
|> visit("/field-wrapper-errors")
|> assert_has(closest(~l".fieldset"c, from: ~l"textbox:Email"r), ~l"can't be blank")

5. Multi-User + Multi-Tab

primary =
session()
|> visit("/session/user/alice")
tab2 =
primary
|> open_tab()
|> visit("/session/user")
user2 =
session()
|> visit("/session/user")

6. Browser-Only Extensions

import Cerberus.Browser
session =
session(:browser)
|> visit("/browser/extensions")
|> type("hello", selector: "#keyboard-input")
|> press("Enter", selector: "#press-input")
evaluate_js(session, "setTimeout(() => document.getElementById('confirm-dialog')?.click(), 10)")
session =
session
|> assert_dialog(~l"Delete item?"e, accept: false)
|> click(~l"link:Download Report"r)
|> assert_download("report.txt")
session
|> assert_has(~l"Press result: submitted")
|> assert_has(~l"Dialog result: cancelled")
main =
session(:browser)
|> visit("/browser/popup/click")
|> with_popup(
fn source ->
click(source, ~l"button:Open Popup"r)
end,
fn source, popup ->
assert_path(source, "/browser/popup/click")
assert_path(popup, "/browser/popup/destination", query: %{source: "click-trigger"})
end
)

Warning

Cerberus.Browser.* helpers are intentionally browser-only and raise explicit unsupported-operation assertions on non-browser sessions. Cross-origin iframe DOM access is still blocked by browser same-origin policy; use provider-level or parent-page assertions for those flows.

Locator Quick Look

How to choose locators:

Canonical helper constructors:

Composition (advanced):

Sigil ~l:

Role helpers currently support practical aliases used by actions/assertions:

For a longer walkthrough with step-by-step locator examples, see docs/getting-started.md.

Switching Modes

Most tests switch modes by changing only the first session line:

-session()
+session(:browser)
|> visit("/live/counter")
|> click(~l"button:Increment"r)
|> assert_has(~l"Count: 1")

Per-Test Browser Overrides

You can override browser defaults in one test by passing session opts:

session(:browser,
ready_timeout_ms: 2_500,
user_agent: "Cerberus Mobile Spec",
browser: [viewport: {390, 844}]
)
|> visit("/live/counter")

Isolation strategy:

SQL sandbox helper:

metadata = Cerberus.sql_sandbox_user_agent(MyApp.Repo, context)
session(:browser, user_agent: metadata)

Popup behavior:

Same-tab workaround (OAuth-style redirect/result flow):

session(:browser, browser: [popup_mode: :same_tab])
|> visit("/browser/popup/auto")
|> assert_path("/browser/popup/destination", query: %{source: "auto-load"}, timeout: 1_500)
|> assert_has(~l"popup source: auto-load"e)

When workaround is brittle:

In those cases, prefer Browser.with_popup/4 and assert both main and popup sessions directly.

Browser Defaults and Runtime Options

You can configure defaults once:

config :cerberus, :assert_timeout_ms, 300
config :cerberus, :browser,
ready_timeout_ms: 2_200,
ready_quiet_ms: 40,
bidi_command_timeout_ms: 5_000,
runtime_http_timeout_ms: 9_000,
dialog_timeout_ms: 1_500,
screenshot_full_page: false,
screenshot_artifact_dir: "tmp/cerberus-artifacts/screenshots",
show_browser: false

Override precedence is:

Assertion-timeout fallback:

Option scopes:

show_browser: true runs headed by default. headless has higher precedence if both are set.

Because browser runtime + BiDi transport are shared per browser lane, runtime launch options should be treated as invocation-level config (not per-test toggles).

Learn More

Browser Runtime Setup

Cerberus browser tests use WebDriver BiDi. Chrome and Firefox are supported browser targets. Project CI currently runs Chrome lanes only; Firefox lanes remain available for explicit local/manual runs.

Local managed runtime (default) uses configured browser and WebDriver binaries:

config :cerberus, :browser,
chrome_binary: "/path/to/chrome-or-chromium",
chromedriver_binary: "/path/to/chromedriver",
firefox_binary: "/path/to/firefox",
geckodriver_binary: "/path/to/geckodriver"

Only the selected browser lane needs to be configured for a given run.

Headed mode:

config :cerberus, :browser, show_browser: true

Remote runtime mode:

config :cerberus, :browser,
webdriver_url: "http://127.0.0.1:4444"

With webdriver_url set, Cerberus does not launch local browser/WebDriver processes.

For explicit multi-browser remote lanes in one invocation:

config :cerberus, :browser,
chrome_webdriver_url: "http://127.0.0.1:4444",
firefox_webdriver_url: "http://127.0.0.1:5555"

Remote webdriver_url integration smoke test (Docker required):

CERBERUS_REMOTE_WEBDRIVER=1 mix test test/cerberus/remote_webdriver_behavior_test.exs

This test starts a selenium/standalone-chromium container with docker run, connects Cerberus through webdriver_url, and force-removes the container on exit.

Global remote-browser invocation (Docker required):

mix test.websocket
mix test.websocket test/cerberus/explicit_browser_test.exs

mix test.websocket starts/stops Selenium container(s) and runs one mix test invocation with remote browser lane wiring. Use --browsers when needed to override lane provisioning (chrome, firefox, or all); prefer Chrome for regular local runs.

Mixed-driver local browser run:

mix test test/cerberus

Cerberus uses mixed-driver suites (no dedicated :browser tag lane), so browser coverage runs as part of normal test/cerberus execution.

Explicit browser-lane override coverage:

mix test test/cerberus/explicit_browser_test.exs

Install local browser runtimes with public Mix tasks:

mix cerberus.install.chrome --version 146.0.7680.31
mix cerberus.install.firefox --firefox-version 148.0 --geckodriver-version 0.36.0

Both tasks install missing binaries and reuse existing per-version installations. Version precedence is flags first, then matching env vars (CERBERUS_CHROME_VERSION, CERBERUS_FIREFOX_VERSION, CERBERUS_GECKODRIVER_VERSION), then defaults (latest stable Chrome/Firefox and GeckoDriver 0.36.0).

Stable output contracts:

After install, Cerberus automatically discovers local managed-runtime binaries via stable links:

No extra binary-path config is required for normal local runs after installation.

CI-friendly form:

mix cerberus.install.chrome --format env >> "$GITHUB_ENV"
mix cerberus.install.firefox --format env >> "$GITHUB_ENV"

Installed paths are stable per version, for example:

Migration Task

Cerberus includes an Igniter migration task for PhoenixTest codebases:

mix cerberus.migrate_phoenix_test
mix cerberus.migrate_phoenix_test --write test/my_app_web/features

It performs safe rewrites, reports manual follow-ups, and defaults to dry-run diff output.

Migration verification docs are maintainer-focused and kept in the repository under docs/migration-verification*.md.