Current as of June 1, 2026  ·  Tips reflect vibium at that date and are subject to change as the Vibium dev team releases updates
01 Getting Started 02 Navigation 03 Element Selection 04 Interactions 05 Waiting & Sync 06 Screenshots 07 Dialogs 08 Network 09 Storage 10 Multi-Page 11 Keyboard & Mouse 12 Recording 13 Performance 14 Testing Patterns Bug Watch
Browser Automation · vibium
99
Vibium
Tips

A practical field guide for testers and engineers — covering the CLI, MCP tools, and the Python, JavaScript, and Java APIs. From first-navigate fundamentals to advanced patterns for flake-free, maintainable test suites.

99 tips 14 categories June 2026
— 01

Getting Started

Tips 1–5
1
Start the browser explicitly before any page work.
browser.start() (or vibium start in the CLI) must be called before navigation. Skipping it causes silent failures on the first navigate call.
2
Always call browser.stop() in a finally block.
If your script throws, an orphaned browser process stays alive. Wrap automation in try/finally to guarantee cleanup regardless of test outcome.
3
The browser launches visible by default — pass headless: true only when you need to hide it.
headless defaults to false. During development that's exactly right: you can watch actions, spot wrong selectors, and see dialog popups as they appear. Pass headless: true explicitly for CI runs or batch jobs where a visible window adds no value.
4
Set a base URL constant, not inline strings.
Store the root URL once. Build all paths from it. When the environment changes (dev → staging → prod), you change one line instead of hunting through every navigate call.
5
Share one browser instance per test session.
Spinning up a browser is expensive. One instance per test suite is the norm. Isolate state between tests with fresh pages or storage resets, not fresh browser launches.
— 03

Element Selection

Tips 16–25
16
Prefer role + name selectors over CSS for interactive elements.
find({ role: "button", text: "Submit" }) is more resilient than find(".btn-primary") and also validates that the element is correctly labelled for screen readers.
17
page.find({ text }) returns the outermost matching element.
If you need a specific interactive child (a button inside a card), always add role to narrow the match. Text-only selectors frequently select a wrapper div that can't receive clicks.
18
Use findAll() when you need every match — find() silently returns the first one.
find() always resolves to the first element that matches, even when many do. It does not throw on multiple matches. Use findAll() when you need the complete set — all rows in a table, all error messages, all checkboxes — and then index or iterate over the result.
19
Avoid generated class names.
CSS-in-JS frameworks regenerate class names on every build. Selectors like .sc-abc123 break silently after a deploy. Use data-testid, ARIA roles, or visible text instead.
20
Chain element context for scoped lookups.
parent.find(selector) searches within parent, not the whole page. Use this to disambiguate elements that appear in multiple sections — e.g. "Edit" buttons in a list of cards.
21
Verify visibility before interacting.
isVisible() returns false for display:none, visibility:hidden, and zero-opacity elements. Clicking an invisible element either silently no-ops or throws — both hide the real bug.
22
Use attr() to read non-visible state.
aria-expanded, data-state, and custom attributes carry state that isn't visible in rendered text. Read them to assert before and after interactions.
23
isEnabled() catches disabled form controls.
A disabled button may still be visually present. Check isEnabled() before asserting a click had an effect; otherwise you may assert on stale state from before the button became disabled.
24
isChecked() is the correct assertion for checkboxes and radios.
Don't infer checked state from class names or attributes. isChecked() reads the actual DOM property, which is what the browser and assistive technology use.
25
value() reads the current input value.
Use it to assert that a prefilled form field has the right default, or to confirm a programmatic fill() actually landed in the element rather than being swallowed by a focus guard.
— 04

Interactions

Tips 26–35
26
fill() clears the field before typing.
Unlike type(), fill() replaces the current value atomically — and works on <textarea> elements as well as <input>. Use it for any text field unless you specifically need to test append or cursor-position behavior.
27
Use type() when the app listens to individual key events.
Autocomplete dropdowns, masked inputs, and PIN fields fire on individual key events. fill() bypasses these; type() fires keydown, keypress, and keyup for each character.
28
click() includes auto-wait for visibility and enabled state.
You do not need a separate wait before most clicks. The built-in auto-wait handles the common case. Only add explicit waits when you need a specific DOM state beyond visible+enabled.
29
Use dblclick() for controls that require a double-click.
Single-clicking a cell in an editable data grid typically selects it; double-clicking opens the editor. Don't simulate with two sequential click() calls — the browser treats them as different events.
30
selectOption() matches by value attribute, not visible text.
Visible option text is locale-sensitive and can change with i18n. The value attribute is stable. Pass the value when the option text might vary across environments. Passing a value that matches no option correctly errors — making invalid selectors immediately visible in test output rather than silently leaving the select unchanged.
31
check() and uncheck() are safer than click() on checkboxes.
They're idempotent: check() does nothing if the box is already checked. click() would toggle it to unchecked — wrong when prior test state is unknown.
32
Use hover() to trigger tooltip and dropdown states.
Hover-activated UI elements don't appear in the DOM until the hover event fires. Don't assert their content without hovering first — the element simply won't be found.
33
Fall back to low-level mouse events for complex drag-and-drop.
If dragTo() doesn't trigger custom drag listeners, use mouse.down()mouse.move()mouse.up() to build the sequence manually with precise coordinates.
34
scrollIntoView() before interacting with off-screen elements.
Lazy-loaded images, sticky footers, and fixed banners can block clicks on elements that aren't scrolled into the viewport. Scroll first, click second.
35
Use focus() + keyboard.press() to test keyboard-only workflows.
Don't use click() as a proxy for keyboard activation. Explicit focus() + keyboard.press("Enter") tests the real keyboard path and catches issues with keydown vs click handlers.
— 05

Waiting & Synchronization

Tips 36–45
36
waitUntil.loaded() after visible text is the most reliable async confirmation.
After a form submit, wait for the success message text rather than sleeping. This ties the wait to observable user-visible state, not an arbitrary time budget.
37
waitUntil(fn) is the escape hatch for custom conditions.
Pass a JavaScript predicate as a full function expression — bare boolean expressions never resolve:
page.waitUntil("() => window.__app.ready === true")
38
Set a global default timeout that matches your app's SLA.
The default may be too short for CI under load or too long for fast unit-like checks. Calibrate it explicitly so timeout failures are meaningful signals, not infrastructure noise.
39
Override timeout per call for known slow operations.
Database migrations, cold-start API calls, and large file uploads are legitimately slow. Pass a local timeout override rather than raising the global and masking real regressions.
40
Two places you'll always need an explicit load wait: after a click that navigates, and after a form submit. #142
go() waits internally, so no explicit wait is needed there. But click() and form submits don't wait for the resulting navigation — you must call waitUntil.loaded() or waitUntil.url(pattern) yourself.
41
Don't assert immediately after triggering an async action.
Animations, debounced handlers, and React batched state updates all add latency between action and visible change. Always wait for the expected state to appear before asserting.
42
Use waitUntil.url() after OAuth redirects.
SSO flows redirect through multiple domains. Waiting for the final destination URL prevents accidentally asserting on an intermediate redirect page that happens to load quickly.
43
A small page.wait(ms) between steps mimics real user pacing.
In exploratory or manual-simulation scripts, a 200–500ms pause reduces false positives from app-side race conditions triggered by superhuman interaction speed.
44
Poll with waitUntil(fn) instead of looping with wait().
A manual polling loop adds noise and requires manual timeout math. waitUntil(fn) handles polling internally and throws a clean, descriptive timeout error when the condition isn't met. Always pass a full function expression — bare expressions time out regardless of the condition's actual state.
45
Test timeout failure paths too.
Intentionally wait for an element that won't appear. Confirm your error handling and test reporter show a clear message. Untested error paths are where frameworks silently swallow failures.
— 06

Screenshots & Visual Testing

Tips 46–52
46
Screenshot every meaningful state transition.
Capture before and after key actions. Screenshots are your primary debugging tool when a test fails in CI where you can't watch the browser live.
47
Use pdf() for print-layout testing — but only in headless mode.
PDF rendering exposes layout bugs invisible in the browser viewport — broken pagination, clipped content, missing page headers, and font-fallback issues unique to the print media type. Note: pdf() only works when the browser is running headless; calling it with a visible window returns an error.
48
Name screenshots descriptively.
screenshot_001.png tells you nothing. checkout_payment_step_filled.png makes a failing test self-documenting without needing to cross-reference a log file.
49
Capture full-page screenshots for content below the fold.
Pass { fullPage: true } when relevant content requires scrolling. Viewport-only screenshots miss footer issues, lazy-loaded content, and sticky element overlap problems.
50
Use the CLI's vibium highlight to annotate elements before screenshotting.
The CLI command vibium highlight "<selector>" overlays a colored outline for three seconds, making a follow-up screenshot immediately readable. This is CLI-only — the JS, Python, and Java APIs don't expose a highlight method; use dispatchEvent or inject a style via evaluate() to achieve the same effect.
51
vibium diff map detects interactive element changes between steps.
vibium diff map compares the current element map against the previous one — showing which labelled interactive elements appeared, disappeared, or changed. It is a structural diff of the page's interactive surface, not a pixel diff of screenshots.
52
Capture a screenshot on every test failure, unconditionally.
In your catch/finally block, always take a failure screenshot. The screenshot reveals the actual page state versus expected state faster than reading stack traces.
— 07

Dialog Handling

Tips 53–58
53
Register dialog handlers before triggering the opening action.
Dialogs fire synchronously in some contexts. If you register the handler after the trigger, the dialog may already be open and unhandled, causing a race.
54
Use dialog.dismiss() to test cancellation flows.
Testing that Cancel does the right thing requires dismissing, not accepting. Separate your accept and dismiss test cases — they exercise different code paths in the application.
55
Assert dialog.message() before accepting or dismissing.
Confirm the app is showing the correct message before deciding the outcome. Missing this step makes tests that accept wrong-message dialogs useless.
56
Unhandled dialogs are auto-dismissed — register onDialog when you need to assert or control the outcome.
If no dialog handler is registered, vibium automatically calls dialog.dismiss() when a dialog appears. The page won't hang. Register onDialog (or use capture.dialog) when you need to assert the message text, accept instead of dismiss, or supply input to a prompt.
57
prompt dialogs accept an input value as an argument to accept().
Pass the text to enter when accepting: dialog.accept("my-input-value"). This enables testing input-dependent prompt flows — confirmation codes, rename dialogs, and similar.
58
Inside capture.dialog, never await the trigger — and always chain .catch(() => {}). #146
The callback form uses fn().then(() => dialogPromise)fn is fully awaited before the dialog promise is checked. If fn awaits a click that blocks until the dialog is dismissed, fn never resolves and the dialog promise is never reached: deadlock. Fire the click without await and add .catch(() => {}) to suppress the unhandled rejection that fires when the blocked click eventually aborts.
// WRONG — deadlock
const dialog = await page.capture.dialog(async () => {
  await page.find({ role: 'button', text: 'Delete' }).click();
});

// CORRECT — fire-and-forget with catch
const dialog = await page.capture.dialog(async () => {
  page.find({ role: 'button', text: 'Delete' }).click().catch(() => {});
});
— 08

Network & Routes

Tips 59–64
59
Use route interception to test error states without a broken backend. #128
Intercept API calls and return 500, 429, or 503 responses on demand. This is more reliable than coordinating with a backend team to force server errors in a shared test environment.
60
Mock slow responses to test loading states.
Add artificial latency inside a route handler to test spinners, skeleton UIs, and progress indicators that are invisible when the real API responds in under 50ms.
61
Register specific routes before catch-alls — the first match wins.
Route matching is ordered. Put your specific mocks first; add the broad **/api/** pass-through last, so it doesn't shadow the routes you actually care about.
62
Explicitly call route.continue() for unmatched routes in partial mocks.
In a partial mock setup, routes that don't match a mock handler must be explicitly passed through. Otherwise those requests are silently dropped and the page loads in a broken state.
63
Assert request payload, not just the response.
Route interception lets you read the request body. Confirm the app sends the right data — correct fields, correct encoding, correct method. A test that only asserts on the response misses half the contract.
64
Remove route handlers between tests.
Stale route handlers from a previous test can intercept requests in the next one, causing confusing failures. Use page.unroute(pattern) explicitly in teardown, not just at process exit.
— 09

Storage & Cookies

Tips 65–70
65
Use context.cookies() to assert session cookies after login.
After login, verify the session cookie's name, domain, secure flag, and SameSite attribute — not just that the dashboard loads. Security properties of auth cookies are easy to regress silently.
66
context.setCookies() seeds auth state without going through the login UI.
Set a valid session cookie before navigating to a protected page. This skips the login flow entirely and keeps tests that need auth state fast and decoupled from login page changes.
67
context.clearCookies() is the cleanest way to test logged-out state.
Clearing cookies is more reliable than clicking "Sign out," which can fail if the logout button changes. Cookie deletion is always available regardless of application state.
68
context.storage() snapshots and context.setStorage() restores full browser state.
Save authenticated state to a file once per CI run. Restore it at the start of every test that needs it. This pattern eliminates login flows from every test without coupling tests to each other.
69
setStorage() doesn't navigate — call go() after restoring.
The restored storage takes effect in the current origin context. You still need to load the page to trigger the app's session initialization logic and hydrate the UI with the restored state.
70
Use page.clock to test JavaScript-driven session expiry — not HTTP cookie expiry.
page.clock overrides Date.now(), setTimeout, and related APIs in the page's JavaScript context. It does not affect the browser's HTTP-level cookie jar. Use the clock to test apps that check expiry in JS — e.g. reading a JWT exp claim or running a countdown timer. For HTTP-level cookie expiry, set short-lived cookies and wait, or clear them manually with clearCookies().
— 10

Multi-Page & Frames

Tips 71–76
71
Use browser.pages() to enumerate open tabs.
Pop-ups and target="_blank" links open new pages. pages() lets you find and switch to them by title or URL instead of guessing page indices.
72
Switch to the new page immediately after a link opens a new tab.
Commands issued after a new tab opens still go to the original page unless you switch. The error shows up as element-not-found in the original page, not as a "wrong page" error — confusing to debug.
73
Explicitly close popup windows with page.close().
Don't rely on garbage collection. Unclosed page handles accumulate over a long test run, consume memory, and can cause assertions to hit the wrong page when switching by index.
74
page.frame() returns an iframe as a new Page context.
Elements inside an iframe are not reachable from the parent page context. Get the frame page via page.frame(nameOrUrl) and interact through it. Forgetting this produces "element not found" errors for elements that are clearly visible.
75
Use page.frames() to find the right frame by URL or name.
When multiple iframes are present, enumerate them to find the right one by URL pattern or name attribute rather than hard-coding a frame index that breaks when the page structure changes.
76
Keep a reference to the parent page — frame pages are separate objects.
page.frame() returns a new Page object scoped to that frame. Your original page variable still points to the parent. Store both explicitly if you need to switch between them.
— 11

Keyboard & Mouse

Tips 77–82
77
page.keyboard.press() sends keyboard shortcuts to the focused element.
page.keyboard.press("Control+a") selects all text in the focused input. Use modifier+key combinations for shortcuts; don't simulate them by clicking selection handles.
78
element.press() sends a single key while the element is focused.
Use standard key names: "Enter", "Escape", "Tab", "ArrowDown". Raw character strings work for printable characters but are ambiguous for function and navigation keys.
79
mouse.move() before mouse.down() for drag simulations.
Custom drag implementations listen to mousemove. Moving to the start position before pressing gives the app a starting coordinate it can use to compute drag delta.
80
Right-click via CLI or MCP — the JS API's mouse.click() is left-button only.
CLI: vibium mouse click x y --button 2. MCP: browser_mouse_click with button: 2. In the JS API, page.mouse.click(x, y) has no button parameter and always fires button 0. For right-click in the JS API, dispatch a contextmenu event via page.evaluate().
81
Test Tab order by pressing Tab and reading document.activeElement.
page.evaluate("document.activeElement.id") tells you which element has focus after each Tab press. This is the only reliable way to automate WCAG 2.4.3 focus order testing.
82
Call uncheck() then check() when state is unknown.
If a prior test left a checkbox in an unpredictable state, the idempotent pair uncheck → check guarantees a known starting state without reading current state first.
— 12

Recording & Debugging

Tips 83–88
83
recording.start() / recording.stop() captures a trace archive of the test run.
The output is a ZIP archive containing screenshots, BiDi event traces, and action metadata — upload it to player.vibium.dev to replay the session step by step. Enable it for flaky tests to capture exactly what happened without reproducing the failure locally. JS API: page.context.recording.start(). CLI: vibium record start.
84
Use recording.startGroup(name) to annotate phases of a long test.
Named groups segment the trace into labelled sections (login, search, checkout) so navigation in player.vibium.dev is meaningful. A single monolithic trace with no group markers makes it hard to jump to the point where things went wrong.
85
page.evaluate() runs arbitrary JavaScript in the page. #124 #144
Use it to read hidden state, trigger events with no UI surface, or measure performance timings: page.evaluate("performance.getEntriesByType('navigation')[0].loadEventEnd").
86
page.content() reveals the raw DOM when visual inspection isn't enough.
When an element looks correct in the screenshot but assertions fail, dump the page HTML. You'll see attributes, data properties, and child structure that the rendered view hides.
87
page.title() is the fastest sanity check after navigation.
If title() returns something unexpected, you're on the wrong page. This single assertion catches redirect failures, auth walls, and 404 pages before deeper assertions produce confusing errors.
88
page.a11yTree() exposes the full accessibility tree.
The tree shows computed ARIA roles, names, states, and parent-child relationships. Use it to audit accessibility without a separate axe-core integration — it's always available, no plugin required.
— 13

Performance & Scale

Tips 89–93
89
Reuse browser contexts across tests with the same auth state.
Creating a new context per test adds significant overhead. Group tests that share an authenticated session under one context; only create new contexts at actual session boundaries.
90
Use mobile viewport dimensions to test responsive layouts.
page.setViewport({ width: 375, height: 812 }) emulates an iPhone viewport. Mobile-specific breakpoints, touch targets, and nav patterns are invisible at desktop width.
91
page.emulateMedia() toggles dark mode and print media without OS changes.
page.emulateMedia({ colorScheme: "dark" }) applies dark mode instantly. Test dark/light mode visual regression in the same CI run without system-level configuration changes.
92
page.setGeolocation() tests location-aware features without a real device.
Simulating a GPS coordinate is faster and fully repeatable. Test location-dependent content, store finders, and geo-restricted features across any coordinate without physical travel.
93
Run tests in parallel across multiple browser pages.
Vibium pages are independent. Launch several pages concurrently and distribute test cases across them. A 10-test suite running in parallel on 5 pages can cut wall-clock time roughly in half.
— 14

Testing Patterns

Tips 94–99
94
Arrange–Act–Assert, always.
Set up state (navigate, fill, wait). Perform the single action under test. Assert the result. One action per test makes failures unambiguous — you know exactly what triggered the failure.
95
Assert what the user sees, not implementation details.
Check visible text, ARIA states, and URL — not class names, internal store values, or network call counts (unless those are the feature under test). Tests that assert on user-visible behavior survive refactors.
96
Test the unhappy path as rigorously as the happy path.
Validation errors, network failures, empty states, and permission denials are what users encounter when things go wrong. They deserve the same coverage — arguably more, since they're harder to design correctly.
97
Use page.clock to test time-dependent UI without waiting.
Countdown timers and date-relative displays can all be tested by fast-forwarding the browser's JavaScript clock. A test that waits 30 minutes for a session to expire is not a test — it's a liability.
98
Isolate each test's browser state.
Clear cookies, local storage, and session storage between tests. A test that passes in isolation but fails in a suite has a state leak. Find it — don't work around it with ordering hacks.
99
Write selectors that survive a redesign.
CSS classes and DOM structure change. Selectors anchored to user-visible text and ARIA roles stay valid as long as the feature's behavior is unchanged. Your tests should break when behavior changes — not when a developer renames a CSS class.
— Bug Watch

Open Issues

5 confirmed open issues on VibiumDev/vibium map directly to tips in this article. Gold badges on tip cards link to the relevant issue.

JavaScript API
Tip 85
page.evaluate() wraps nested array strings as BiDi typed objects instead of plain strings.JS API
Tips 7, 10
capture.navigation() and page.url() both miss SPA history.pushState() navigations.JS API
Python Client
Tip 58
capture.dialog(fn) deadlocks when fn calls page.evaluate("alert(...)") — same fire-and-forget rule applies.Python client
Java Client
Tips 59–64
page.route() and page.setHeaders() cause page.go() to deadlock permanently — network interception blocks navigation.Java client
CLI
Tip 40
vibium click hangs during recording when a POST submit redirects back to the same form page.CLI

The Thread Running Through All 99

Every tip here traces back to the same idea: test what the user experiences, not how the code is written. Selectors that survive refactors, waits tied to visible state, assertions on user-facing text — these hold up because they model the product from the outside, the same way a user does.

The second thread is determinism. Sleeps, index-based selectors, and shared state are the three root causes behind almost every flaky test. Replace each one with a condition-based wait, a semantic selector, and isolated state — and a suite that flakes 30% of the time becomes one that fails when it should and passes when it should.

Vibium gives you the tools. These 99 tips are the judgment layer on top — knowing which tool to reach for, and why.

Test user behavior Wait for state, not time Isolate everything Semantic selectors Fail loudly, clearly