Actionability Checks: How Vibium Guarantees Reliable Interactions
The Problem
Web pages are dynamic. When you tell an automation tool to "click the submit button," the button might:
- Not exist yet (still loading)
- Be invisible (hidden by CSS)
- Be moving (CSS animation in progress)
- Be covered by another element (modal overlay, sticky header, tooltip)
- Be disabled (form validation hasn't passed)
A naive tool clicks immediately and fails. A sophisticated tool waits for the element to be ready.
The Five Checks
Vibium implements five actionability checks server-side in Go (not in client libraries). This is a critical architectural choice — it means all clients (JS, Python, CLI) get identical behavior.
Check 1: Visible
Question: Does the element have physical dimensions and is it not hidden by CSS?
(selector) => {
const el = document.querySelector(selector);
if (!el) return JSON.stringify({ error: 'not found' });
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return JSON.stringify({ visible: false, reason: 'zero size' });
}
const style = window.getComputedStyle(el);
if (style.visibility === 'hidden') {
return JSON.stringify({ visible: false, reason: 'visibility hidden' });
}
if (style.display === 'none') {
return JSON.stringify({ visible: false, reason: 'display none' });
}
return JSON.stringify({ visible: true });
}
What it catches:
- Elements with
display: noneorvisibility: hidden - Elements with zero width or height (collapsed containers)
- Elements that haven't rendered yet
Check 2: Stable
Question: Has the element stopped moving? (Position unchanged over 50ms)
// Run getBoundingClientRect() at time T
// Wait 50ms
// Run getBoundingClientRect() at time T+50ms
// Compare: if position/size changed, element is NOT stable
The Go code runs getBoundingClientRect() twice with a 50ms gap and compares results.
What it catches:
- CSS transitions (slide-in panels, expanding accordions)
- CSS animations (bouncing buttons, rotating spinners)
- JavaScript-driven animations
- Layout shifts from async content loading
Why 50ms? It's long enough to catch most animations (which run at 16ms/frame) but short enough to not add noticeable delay.
Check 3: ReceivesEvents
Question: If we click at the element's center, will the click actually reach it?
(selector) => {
const el = document.querySelector(selector);
if (!el) return JSON.stringify({ error: 'not found' });
const rect = el.getBoundingClientRect();
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
// What element is actually at this point?
const hitTarget = document.elementFromPoint(centerX, centerY);
if (!hitTarget) {
return JSON.stringify({ receivesEvents: false, reason: 'no element at point' });
}
// Is it our element, or a child of our element?
if (el === hitTarget || el.contains(hitTarget)) {
return JSON.stringify({ receivesEvents: true });
}
// Something else is covering it
return JSON.stringify({
receivesEvents: false,
reason: 'obscured by ' + hitTarget.tagName.toLowerCase()
});
}
What it catches — the most subtle bugs:
- Modal overlays covering buttons
- Sticky headers overlapping content when scrolled
- Cookie consent banners blocking the page
- Tooltips positioned over clickable elements
- Invisible overlay
<div>s with high z-index
The el.contains(hitTarget) check: If a <button> contains a <span> with the text, clicking the center might hit the <span>. That's fine — the click will bubble up to the button. So we check if the hit target is a child of our target.
Check 4: Enabled
Question: Will the element respond to interaction?
(selector) => {
const el = document.querySelector(selector);
if (!el) return JSON.stringify({ error: 'not found' });
if (el.disabled === true) {
return JSON.stringify({ enabled: false, reason: 'disabled attribute' });
}
if (el.getAttribute('aria-disabled') === 'true') {
return JSON.stringify({ enabled: false, reason: 'aria-disabled' });
}
// Check if inside disabled fieldset
const fieldset = el.closest('fieldset[disabled]');
if (fieldset) {
const legend = fieldset.querySelector('legend');
if (!legend || !legend.contains(el)) {
return JSON.stringify({ enabled: false, reason: 'inside disabled fieldset' });
}
}
return JSON.stringify({ enabled: true });
}
What it catches:
<button disabled>elementsaria-disabled="true"(accessibility-first disabled state)- Elements inside
<fieldset disabled>(except those in the first<legend>)
Check 5: Editable (Type-Only)
Question: Does this element accept text input?
(selector) => {
const el = document.querySelector(selector);
if (!el) return JSON.stringify({ error: 'not found' });
if (el.readOnly === true) {
return JSON.stringify({ editable: false, reason: 'readonly attribute' });
}
if (el.getAttribute('aria-readonly') === 'true') {
return JSON.stringify({ editable: false, reason: 'aria-readonly' });
}
const tag = el.tagName.toLowerCase();
if (tag === 'input') {
const type = (el.type || 'text').toLowerCase();
const textTypes = ['text', 'password', 'email', 'number', 'search', 'tel', 'url'];
if (!textTypes.includes(type)) {
return JSON.stringify({ editable: false, reason: 'input type ' + type + ' not editable' });
}
}
if (el.isContentEditable) return JSON.stringify({ editable: true });
if (tag === 'input' || tag === 'textarea') return JSON.stringify({ editable: true });
return JSON.stringify({ editable: false, reason: 'not a form element or contenteditable' });
}
Only runs for type commands. Not needed for click, hover, etc.
Which Checks Run for Which Actions?
| Action | Visible | Stable | ReceivesEvents | Enabled | Editable |
|---|---|---|---|---|---|
| Click | Yes | Yes | Yes | Yes | No |
| Type | Yes | Yes | Yes | Yes | Yes |
| Hover | Yes | Yes | Yes | No | No |
The Auto-Wait Loop
Checks don't run once — they run in a polling loop:
deadline = now + timeout (default 30 seconds)
loop:
for each check in required_checks:
if check fails:
if now > deadline:
return TimeoutError("timeout after 30s waiting for
'button.submit': check 'ReceivesEvents' failed —
obscured by div.modal-overlay")
sleep 100ms
continue loop ← restart all checks
// ALL checks passed on the SAME iteration
perform action
Key behaviors:
- 100ms polling interval — fast enough to catch state changes, not so fast it hammers the browser
- 30s default timeout — configurable per-action via
--timeout - All checks must pass in the same iteration — if Visible passes but Stable fails, both are re-checked
- Descriptive timeout errors — tells you WHICH check failed and WHY
Why Server-Side (Go) Not Client-Side?
| Reason | Explanation |
|---|---|
| Single implementation | Written once in Go, not duplicated in JS + Python + CLI |
| Reduced latency | Polling happens over local WebSocket, not client→proxy→browser round trips |
| Simpler clients | Client code is trivial: send command, wait for success/error |
| Consistent behavior | All clients get identical timing — no "works in JS, fails in Python" bugs |
The client code for a click becomes just:
async click(options?: { timeout?: number }): Promise<void> {
await this.client.send('vibium:click', {
context: this.context,
selector: this.selector,
timeout: options?.timeout,
});
}
All complexity lives in the Go binary.
Comparison with Playwright
Vibium's actionability concept comes directly from Playwright, which pioneered this approach:
| Aspect | Playwright | Vibium |
|---|---|---|
| Where checks run | Client library (JS/Python) | Server (Go binary) |
| Check list | Same five checks | Same five checks |
| Default timeout | 30s | 30s |
| Customizable | Per-action | Per-action |
| Retry interval | ~100ms | ~100ms |
The key difference is where the logic lives. Playwright implements it in each client library. Vibium implements it once in the Go binary, making all clients automatically consistent.
Interview Talking Point
"Vibium implements Playwright-style actionability checks — visible, stable, receives-events, enabled, and editable — but does it server-side in the Go binary rather than in client libraries. This means every client (JavaScript, Python, CLI) gets identical behavior without duplicating the logic. The checks run in a 100ms polling loop with a 30-second default timeout. When a check fails at timeout, the error message tells you exactly which check failed and why — for example, 'obscured by div.modal-overlay' — which makes debugging trivial. This is the same pattern Playwright established, but with a more maintainable architecture."