Browser Testing
Drive a real headless Chrome from your specs — no Node, no npm, no Playwright.
Request specs test what the server sends. Browser specs test what the user gets: a real browser loads the page, runs its JavaScript, and you drive it with helpers that read like the HTTP ones you already use.
describe("checkout", fn() {
test("a customer can place an order", fn() {
visit("/cart")
fill_in("Coupon", "SAVE10")
click_button("Apply")
assert_text("Discount applied")
assert_no_page_errors()
})
})
Soli speaks the Chrome DevTools protocol from the binary itself. The only requirement is a Chromium-family browser on the machine — nothing to install into your project, and nothing to keep in sync.
Running browser specs
Browser specs are opt-in: they need a browser and cost seconds rather than milliseconds.
soli test --browser # run everything, browser specs included
soli test --headed # same, but watch it happen in a window
A spec counts as a browser spec when a directory called browser appears anywhere in its path. Plain soli test sets those aside and says so — a project with no browser installed still runs green.
tests/
users_spec.sl # always runs
browser/
checkout_spec.sl # only with --browser
$ soli test
Skipping 1 browser spec(s) — add --browser to run them.
Choosing a browser
Soli looks for google-chrome, chromium, microsoft-edge and brave-browser on PATH (plus the usual /Applications paths on macOS). Point it elsewhere with SOLI_CHROME_PATH. If nothing is found, --browser fails immediately with what it looked for, rather than thirty seconds later on the first visit().
Navigating
visit("/posts") # relative to this worker's test server
visit("https://example.com") # absolute URLs work too
page_path() # "/posts?page=2"
page_url() # full URL
page_title() # the <title>
page_text() # visible text, as the user sees it
page_html() # full markup
visit returns once the document has finished loading, so any script the page runs on boot has already run.
Viewport
Every spec runs at a fixed 1280×800 — not “whatever the browser opens with” — so a responsive layout renders the same on your machine and in CI. Declare a different one in the spec:
describe("navigation on a phone", fn() {
viewport("mobile") # applies to every test below
test("the menu collapses", fn() {
visit("/")
assert_selector(".menu-toggle")
assert_no_selector(".sidebar")
})
})
The declaration belongs to the suite, like before_each: every test in the describe starts in it, and a nested describe inherits it unless it declares its own.
describe("dashboard", fn() {
viewport("mobile")
context("on a wide screen", fn() {
viewport("wide") # overrides, for this block only
test("shows both panes", fn() { ... })
})
test("stacks the panes", fn() { ... }) # still the phone
})
Sizes can be a preset, a "WxH" string, or two numbers:
viewport("iphone_se")
viewport("1024x768")
viewport(1024, 768)
viewport(1024, 768, {"scale": 2, "mobile": true})
| Preset | Size | Pixel ratio | Emulates a device |
|---|---|---|---|
mobile, iphone | 390×844 | 3 | yes |
iphone_se | 375×667 | 2 | yes |
android | 412×915 | 2.6 | yes |
tablet, ipad | 820×1180 | 2 | yes |
laptop | 1280×800 | 1 | no |
desktop | 1440×900 | 1 | no |
wide | 1920×1080 | 1 | no |
Names are matched loosely, so "iPhone SE" and "iphone-se" are the same request.
Device emulation is more than a narrow window. The phone and tablet presets also set the pixel ratio and turn on touch, so matchMedia("(pointer: coarse)") matches and a page that only binds touch handlers is reachable. Pass {"mobile": true} to get the same for an explicit size; a bare size stays a desktop, so breakpoint tests are never silently handed a touch device.
One consequence is worth knowing: with device emulation on, a page without <meta name="viewport" content="width=device-width"> lays out at 980 CSS pixels — exactly what a real phone does with it. If a mobile spec sees the desktop layout, that missing tag is usually why. Soli's generated layout has it.
Resize inside a test when the resize is the thing under test. viewport() with no arguments reads the current one back as {"width": 390, "height": 844, "scale": 3, "mobile": true}.
test("the sidebar collapses when the window narrows", fn() {
visit("/dashboard")
assert_selector(".sidebar")
viewport("mobile")
assert_no_selector(".sidebar")
})
Interacting
click("#save") # CSS selector
click_link("Edit") # a link by its text
click_button("Save") # a button by its label or value
fill_in("#title", "Hello") # by selector
fill_in("Full name", "Ada") # by label text
fill_in("email", "a@b.c") # by name or placeholder
select_option("#role", "admin") # by value or visible text
check("#agree")
uncheck("#agree")
choose("#plan_pro")
press("Enter")
press("Alt+d") # chords: Alt, Ctrl, Shift, Meta/Cmd
Selectors resolve leniently: a CSS selector first, then a matching <label>, then a field's name or placeholder. Write what you see on the page rather than what the markup happens to call it.
Clicks are real input events dispatched at the element's position, not element.click(). An element covered by an overlay is not clickable in a browser, and it is not clickable here either — which is the behaviour you want a test to have.
Asserting
assert_text("Saved") # visible text contains this
assert_no_text("Error")
assert_selector("#toast") # element is present
assert_no_selector(".error")
assert_page_path("/posts/1")
assert_no_page_errors() # no uncaught exception or console.error
Positive assertions wait. assert_text("Saved") polls until the text appears or the timeout expires, so a spec never has to guess how long a round trip takes. Negative assertions check immediately — waiting for something to stay absent would only slow every passing test down.
assert_text("Report ready", {"timeout": 30}) # seconds; default is 10
wait_for("#chart", {"timeout": 30})
Waiting explicitly
You need these when the next thing you do is not an assertion — evaluate reads the DOM as it is right now and does not wait.
click("#increment")
wait_for_text("count=1") # wait first…
assert_eq(evaluate("document.title"), "Counter — 1") # …then read
Escape hatches
evaluate("window.appVersion") # any expression, value comes back
screenshot("/tmp/checkout.png") # PNG of the current view
page_errors() # array of captured JS errors
evaluate preserves JavaScript's types: a string stays a string even when it looks like a number, so evaluate("el.textContent") on <span>0</span> gives you "0", not 0.
Signing in
The browser shares the request helpers' cookie jar, so the sign-in you already have keeps working. Cookies flow both ways: a sign-in performed in the browser is visible to a later get() and to signed_in().
before_each(fn() {
login("ada@example.com", "secret") # a real POST /login
})
test("the dashboard greets the user", fn() {
visit("/dashboard") # arrives already signed in
assert_text("Welcome back, Ada")
})
as_user(id) with a single argument only sets a thread-local and does not authenticate a real request. Use the two-argument form — as_user(7, {"role": "admin"}) — which writes the session store and sets the cookie. That one carries into the browser.
What resets between tests
Each test starts with a clean page-error list, empty sessionStorage and localStorage, and the viewport its suite declared. Without that, a panel one test collapsed — or a resize one test performed — would carry into the rest of the suite and results would depend on test order.
Cookies are not cleared automatically, matching the existing convention for request specs — sign out explicitly when a test needs a guest.
The browser is reused for the whole worker rather than relaunched per test, which is why the reset exists at all — and why a suite of thirty browser specs takes seconds rather than a minute.
In CI
- uses: browser-actions/setup-chrome@v1
with:
chrome-version: stable
- run: soli test tests/browser --browser --no-coverage
Browser specs parallelise like any other: each test worker gets its own server and its own browser, so --jobs 3 means three browsers.
Troubleshooting
- “Browser helpers need a browser” — the spec called
visit()without--browser. Either add the flag, or move the spec out of abrowser/directory if it does not need one. - A click reports “found nothing matching” — the helper waited the full timeout and never saw the element. Check it is actually rendered (
page_html()) and not hidden; a zero-size element has no position to click. - Flaky-looking failures — almost always a missing wait around
evaluate. Assertions wait; rawevaluatedoes not. - Unexpected page errors —
page_errors()shows what was captured.assert_no_page_errors()covers uncaught exceptions andconsole.error, not warnings.