QA Engineer Skills 2026QA-2026Actionability Checks: How Vibium Guarantees Reliable Interactions

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: none or visibility: 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> elements
  • aria-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."