VLearnVibium

Vibium Best Practices: The Complete Guide

Vibium best practices for reliable browser automation: semantic locators, actionability waits, page objects, isolation, CI, and AI checks.

By Pramod Dutta··13 min read·Verified with Vibium 26.2
▶ Animated overview · made with Remotion

The core Vibium best practices are: describe elements semantically instead of with brittle CSS, trust the built-in actionability waits instead of sprinkling manual sleeps, isolate each test with its own browser context, wrap screens in page objects so locators live in one place, run headless and version-pinned in CI, and reach for the AI check() and do() methods only when you want to express intent rather than exact steps. Vibium is AI-native browser automation built on WebDriver BiDi by Jason Huggins, co-creator of Selenium and Appium. It ships as a single Go binary that auto-downloads Chrome, exposes a built-in MCP server, and offers first-party Python and JavaScript/TypeScript clients. Because the hard parts — waiting, selector resolution, retries — live in the compiled Go engine, following a handful of disciplined habits keeps your scripts short, fast, and durable. This guide walks through each practice with runnable, verified code you can paste today.

What does a reliable Vibium workflow look like?

Every well-structured Vibium script follows the same five-step rhythm: launch, find, act, assert, clean up. The diagram above is not decoration — it is the pipeline every example in this guide maps onto. Get this shape right and the details fall into place.

Here is that rhythm as a minimal, verified script in both languages. Notice there are no manual waits and no low-level protocol calls — the engine handles them.

const { browser } = require('vibium/sync')
 
const bro = browser.launch()
const page = bro.page()
 
page.go('https://example.com/login')
page.find({ label: 'Email' }).type('alice@example.com')
page.find({ label: 'Password' }).type('s3cret')
page.find({ role: 'button', text: 'Sign in' }).click()
 
console.log(page.find('.welcome').text())
bro.close()
from vibium import browser_sync as browser
 
vibe = browser.launch()
vibe.go("https://example.com/login")
vibe.find(label="Email").type("alice@example.com")
vibe.find(label="Password").type("s3cret")
vibe.find(role="button", text="Sign in").click()
 
print(vibe.find(".welcome").text())
vibe.quit()

The rest of this article is about doing each of those steps well. If you have never run Vibium, start with what is Vibium and install Vibium, then come back.

Which locators should you use in Vibium?

Prefer semantic locators — role, label, text, placeholder, and testid — over deep CSS paths, because they describe an element by what it means to a user rather than by where it happens to sit in the DOM. A button found by find(role="button", text="Sign in") keeps working when a designer renames a class or reshuffles a wrapper div; a button found by div.form > button.btn.btn-primary.mt-3 breaks on the next cosmetic refactor.

Vibium's single find() method takes either a CSS string for the common structural case or an options object for semantic strategies, and the semantic keys are combinable — something that requires chaining in other tools.

// Resilient — describes intent, survives cosmetic change
page.find({ role: 'button', text: 'Submit' }).click()
page.find({ label: 'Email' }).type('alice@example.com')
page.find({ testid: 'cart-count' }).text()
 
// Acceptable — a stable structural hook
page.find('#email')
 
// Brittle — one class rename away from breaking
page.find('div.form > button.btn.btn-primary.mt-3').click()

The decision is easy to reason about with a quick table.

SituationRecommended locatorWhy
Buttons, links, form controlsrole + text / labelMatches the accessible name users perceive
Inputs with a visible labellabelSurvives markup changes around the field
Elements with no good semanticstestid (add data-testid)Explicit, layout-proof contract with dev
Stable structural anchors (#id)CSS stringTerse and unambiguous when the id is stable
Deep descendant chainsAvoidBreaks on any wrapper or class change

When the DOM offers no natural semantic anchor, add a data-testid attribute in the app code and target it — that is a deliberate contract between developers and automation, not a hack. For the full reference on every strategy, see finding elements with find() and the dedicated selector best practices guide.

How should you handle waiting in Vibium?

Do not add manual waits — Vibium's find() already waits for actionability before it interacts. Before a click or type, the engine checks that the target exists, is visible, is stable (not animating), receives events (not covered), and is enabled. This is the single biggest source of flakiness in older tools, and Vibium solves it in the Go engine so you do not have to.

That means the two habits below are almost always wrong in Vibium:

// ANTI-PATTERN — fixed sleeps are slow and still race-prone
page.wait(3000)
page.find('.submit').click()
 
// BETTER — the click already waits for the button to be actionable
page.find({ role: 'button', text: 'Submit' }).click()

You still need explicit waits for genuine state transitions the framework cannot infer from a single element — a URL change after login, or an element that should appear or disappear. Use the purpose-built methods for those, not a timer.

from vibium import browser_sync as browser
 
vibe = browser.launch()
vibe.go("https://app.example.com/login")
vibe.find(role="button", text="Sign in").click()
 
# Wait for the real signal, not an arbitrary number of seconds
vibe.wait_for_url("**/dashboard")
vibe.find(".spinner").wait_for(state="hidden")
 
assert "Dashboard" in vibe.title()
vibe.quit()

The rule of thumb: never use a fixed sleep to hope something is ready; wait for the specific condition that proves it is. A deeper treatment lives in waiting strategies, and the reasoning behind the engine's checks is documented in Vibium's actionability guide.

How do you isolate state between tests?

Give every test its own context so cookies, localStorage, and session state never leak between tests. Vibium's object model has three levels — BrowserContextPage — and a context is an isolated cookie jar and storage bucket. You launch the expensive browser process once and hand each test a fresh, disposable context, which is both fast and clean.

const { browser } = require('vibium/sync')
 
const bro = browser.launch()
 
// Each context is a sealed session — perfect per-test isolation
const alice = bro.newContext()
const bob = bro.newContext()
 
const alicePage = alice.newPage()
const bobPage = bob.newPage()
 
alicePage.go('https://chat.app/login')
bobPage.go('https://chat.app/login')
// Alice and Bob have completely separate login state
 
alice.close()
bob.close()
bro.close()

This pattern shines in a test suite. Reuse one browser across the whole run for speed, but wrap each test in its own context and close it afterward so nothing carries over.

from vibium import browser_sync as browser
 
bro = browser.launch()   # one process for the whole suite
 
def test_cart_starts_empty():
    ctx = bro.new_context()          # fresh, isolated state
    vibe = ctx.new_page()
    vibe.go("https://store.example.com")
    assert vibe.check("the cart is empty").passed
    ctx.close()                      # tear down cleanly
 
def test_checkout_flow():
    ctx = bro.new_context()          # unaffected by the test above
    vibe = ctx.new_page()
    # ...
    ctx.close()

There is a performance corollary worth knowing: logging in through the UI on every test is slow. Once you have a good login flow, save the authenticated session with context.storageState() and seed fresh contexts from it, so most tests start already logged in without repeating the sign-in steps. That keeps isolation intact — each context is still independent — while cutting the per-test setup cost dramatically on large suites.

For most quick scripts you never touch contexts — browser.launch() gives you a default one implicitly. Reach for newContext() the moment you need multi-user scenarios or true test isolation. See how to structure a Vibium test suite for wiring this into fixtures, and how to automate a login flow for the sign-in step you will capture once and reuse.

Should you use the Page Object Model?

Yes — for anything larger than a throwaway script, wrap each screen in a page object so locators and actions live in exactly one place. A page object is a class that owns its selectors and exposes methods named after user intent. Tests then call login_page.login(user, pw) instead of repeating four find() calls, and when a button's label changes you edit one method rather than fifty tests.

from vibium import browser_sync as browser
 
 
class LoginPage:
    URL = "https://example.com/login"
 
    def __init__(self, vibe):
        self.vibe = vibe
 
    def open(self):
        self.vibe.go(self.URL)
        return self
 
    def login(self, username, password):
        self.vibe.find(label="Username").type(username)
        self.vibe.find(label="Password").type(password)
        self.vibe.find(role="button", text="Sign in").click()
        return DashboardPage(self.vibe)
 
 
class DashboardPage:
    def __init__(self, vibe):
        self.vibe = vibe
 
    def welcome_text(self):
        return self.vibe.find(".welcome").text()

Because find() auto-waits, these methods stay short — no manual sleep() calls bloating the class, which is a common problem in page objects built on older frameworks. The full pattern, including reusable component objects for shared headers and modals, is covered in the Page Object Model with Vibium.

How do you use Vibium's AI methods without overusing them?

Reserve check() and do() for expressing intent; keep deterministic find()/click()/fill() for the parts of a flow where you know exactly what you are targeting. Vibium's AI methods are its signature feature, but they are a complement to the deterministic API, not a replacement — used well, they remove fragile assertion code; used everywhere, they make runs slower and harder to debug.

check() replaces a brittle chain of DOM reads with one plain-English assertion and returns a structured result you can assert on.

// Instead of scraping and parsing the DOM to prove a fact...
const result = page.check('the shopping cart shows 0 items')
// { passed: true, reason: "Cart icon badge reads 0", confidence: 0.96, ... }
 
if (!result.passed) throw new Error(result.reason)

do() is for when you do not yet know the selectors — it screenshots the page, plans steps with an LLM, and executes them through Vibium's own find()/click()/fill() under the hood. It is AI planning, not AI puppeteering, so the actions stay auditable.

page.do('close the cookie consent banner')
page.do('add the first item to the cart')

The table below is the mental model to keep.

MethodUse it forAvoid it for
find() + actionKnown, repeated steps on the hot path
check(claim)Assertions that are painful as DOM queriesSimple exact-value checks el.value() handles
do(action)Exploratory steps, unknown selectorsDeterministic flows you run thousands of times

A good suite is mostly deterministic find() calls with check() at the assertion boundaries and the occasional do() for exploratory or self-healing paths. That keeps runs fast and failures legible while still capturing the productivity of natural-language automation.

How should you configure Vibium for CI?

Run headless, pin the version, let the binary fetch Chrome on the runner, and capture diagnostics on failure. Because Vibium is a single Go binary that auto-downloads Chrome, CI setup is refreshingly small — no separate driver to install or match to a browser version. The practices below turn a working local script into a dependable pipeline.

const { browser } = require('vibium/sync')
 
const bro = browser.launch({ headless: true })
const page = bro.page()
 
try {
  page.go(process.env.APP_URL)           // config via env, never hardcoded
  page.find({ role: 'button', text: 'Sign in' }).click()
 
  const result = page.check('the dashboard loaded successfully')
  if (!result.passed) throw new Error(result.reason)
} catch (err) {
  const fs = require('fs')
  fs.writeFileSync('failure.png', page.screenshot())   // artifact for triage
  throw err
} finally {
  bro.close()
}

Beyond the code, a few operational habits keep CI green and honest:

PracticeWhy it matters
headless: trueNo display server needed; faster on runners
Pin the vibium version in your lockfileReproducible builds; no surprise upgrades mid-sprint
Secrets in environment variablesKeeps credentials out of the repo and logs
One isolated context per test, run in parallelSpeed without cross-test contamination
Screenshot and trace on failureTurns a red build into an actionable report
Fail the build on any passed: falseA soft assertion is not an assertion

Parallelism deserves a word of its own. Because a single launched browser can host many isolated contexts, you can run tests concurrently inside one process rather than spinning up a browser per worker — cheaper on memory, and each test still sees a clean session. The one rule is that parallel tests must never share a context or a page; give each its own, and races between them disappear.

Capturing a trace gives you a Playwright-compatible timeline of the run to open after a failure — invaluable when a test only breaks on the runner. See take a screenshot with Vibium for capture options and the full CI with GitHub Actions walkthrough for a complete pipeline.

What are the most common Vibium anti-patterns to avoid?

The failure modes below account for most flaky, slow, or unmaintainable Vibium suites. Each one has a direct fix already covered above.

  • Fixed sleeps. page.wait(3000) before an action is redundant — find() already waits for actionability. It only makes suites slower and can still race. Wait for a real condition instead.
  • Deep CSS chains. div > ul > li:nth-child(3) > a breaks on any layout tweak. Use role, text, label, or a testid.
  • Shared mutable state. Reusing one context across tests lets a login or cart leak into the next test. Give each test a fresh newContext().
  • Locators scattered across tests. The same selector copy-pasted into twenty tests is twenty edits when the UI changes. Centralize them in a page object.
  • AI everywhere. Replacing every find() with do() is slower and harder to debug. Keep the hot path deterministic; use AI for intent.
  • Skipping cleanup. Forgetting bro.close() (JS) or vibe.quit() (Python) leaks browser processes, especially in CI. Always tear down in a finally block.
  • Hardcoded URLs and secrets. Bake environment differences into config and env vars so the same script runs locally and on the runner.

Internalizing this list is most of what separates a suite that engineers trust from one they learn to ignore. If you are migrating from another tool, the same principles apply — see Vibium vs Playwright and Vibium vs Selenium for how these habits translate.

How do these practices fit together?

Each practice reinforces the others into a coherent whole. Semantic locators keep page objects durable; page objects keep locators in one place; auto-waiting keeps those methods short; isolated contexts keep tests independent; CI discipline keeps the whole thing honest on every push; and AI methods sit on top for the cases where intent beats exact steps. Adopt them together and Vibium scripts stay small even as the suite grows to hundreds of tests.

If you drive Vibium from an AI agent through its built-in MCP server, these same habits carry over — semantic locators and check() assertions are exactly what an agent needs to act reliably. See Vibium MCP in Claude Code to set that up, and browse the glossary for any term here you want defined precisely.

Next steps

Frequently asked questions

What are the most important Vibium best practices?

Prefer semantic locators (role, label, testid) over brittle CSS, trust Vibium's built-in actionability waits instead of manual sleeps, isolate state with a fresh context per test, wrap screens in page objects, run headless in CI, and reserve AI check() and do() for intent, not everything.

Should I use CSS selectors or semantic locators in Vibium?

Use semantic locators for anything user-facing. find(role='button', text='Sign in') describes the element by what a user sees, so it survives class renames and layout churn. Reserve CSS strings for stable structural hooks like #email, and add data-testid attributes where the DOM has no good semantic anchor.

Do I need manual waits or sleeps in Vibium?

No. Vibium's find() auto-waits for actionability — the element must exist, be visible, stable, and enabled before it acts. Adding time.sleep() or page.wait(ms) on top slows suites and reintroduces flakiness. Use waitFor(state) or waitForURL for genuine state transitions instead of fixed delays.

How do I keep Vibium tests isolated from each other?

Create a fresh context with browser.newContext() per test. Each context has its own cookies, localStorage, and session, so one test's login or cart never leaks into the next. Reuse a single launched browser process across tests for speed, but give every test its own isolated context and close it afterward.

How should I run Vibium in CI?

Launch headless, pin the Vibium version in your lockfile, let it auto-download Chrome on the runner, and capture a screenshot plus trace on failure. Run tests in parallel across isolated contexts, keep secrets in environment variables, and fail the build on any assertion or check() that returns passed: false.

When should I use Vibium's AI check() and do() methods?

Use check() for assertions you would otherwise express as fragile multi-step DOM queries — 'the cart is empty' or 'prices are sorted low to high'. Use do() when you do not know exact selectors yet. Keep deterministic find()/click()/fill() for the hot path; AI methods complement that core, they do not replace it.

Vibium is created by Jason Huggins. This is an independent tutorial — see the official Vibium site and GitHub repo for canonical docs.

Related guides