VLearnVibium

Accessibility Testing with Vibium

Accessibility testing with Vibium — read the a11y tree, assert on roles, names, and states, and catch WCAG issues in CI with no driver setup.

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

To do accessibility testing with Vibium, call a11yTree() to read the page's computed accessibility tree, then assert that every interactive element has the correct role and a non-empty accessible name — that single check catches unlabeled buttons, form fields with no label, images missing alt text, and broken ARIA states. Vibium is AI-native browser automation built on WebDriver BiDi that ships as a single Go binary and auto-downloads Chrome, so pip install vibium or npm install vibium is the only setup. Because it exposes the same accessibility tree that screen readers consume, you assert on what assistive technology actually perceives, not on brittle CSS. Created by Jason Huggins, co-creator of Selenium and Appium, Vibium turns accessibility from a manual audit into a repeatable test you run in CI. It will not replace a full WCAG audit or contrast checker, but it catches a large class of real defects fast.

What is accessibility testing with Vibium?

Accessibility testing with Vibium means inspecting the browser's computed accessibility tree and asserting that interactive elements expose the correct semantics to assistive technology. Instead of eyeballing a page, you programmatically confirm that buttons announce as buttons, inputs carry labels, and images carry alt text.

The core tool is a11yTree(). It returns a structured JSON view of every element as a screen reader sees it — each node has a role (what the element is) and, when a label exists, a name (what it announces). Stateful nodes also expose checked, expanded, disabled, and similar flags.

This matters because the accessibility tree is the ground truth. Sighted users rely on visual cues; screen-reader users rely entirely on the name and role in this tree. A button styled as a <div> with an onclick looks fine but is invisible to keyboard and screen-reader users. Vibium makes that gap testable.

Vibium is not a full audit tool. It does not compute color contrast or run the entire WCAG rule set. What it does exceptionally well is let you assert on names, roles, and states in the same script that already drives your app — closing the loop between functional and accessibility testing.

Why use the accessibility tree instead of the DOM?

The accessibility tree tells you what assistive technology perceives, which is often different from the raw HTML. The DOM shows markup; the accessibility tree shows the computed semantics after ARIA, labels, and browser heuristics are applied.

Consider a button whose only content is an icon:

<button><svg aria-hidden="true">…</svg></button>

The DOM shows a <button> element, so a naive DOM check passes. But the accessibility tree shows a button node with no name — a screen reader announces only "button," giving the user no idea what it does. Asserting on the DOM misses this; asserting on the tree catches it.

The reverse also happens. A <div role="button" aria-label="Close"> looks like a plain div in the DOM but correctly reports as a named button in the accessibility tree. You cannot judge accessibility from HTML tags alone — you have to read the computed tree, which is exactly what a11yTree() returns.

How do I get the accessibility tree in Vibium?

Call a11yTree() on the page after navigation. It returns the full tree as a nested object you can inspect or walk in code.

Here is the JavaScript version using the sync API:

const { browser } = require('vibium/sync')
 
const bro = browser.launch()
const vibe = bro.page()
vibe.go('https://example.com')
 
const tree = vibe.a11yTree()
console.log(JSON.stringify(tree, null, 2))
 
bro.close()

And the Python equivalent:

from vibium import browser_sync as browser
 
vibe = browser.launch()
vibe.go("https://example.com")
 
tree = vibe.a11y_tree()
print(tree)
 
vibe.quit()

A trimmed tree for a login form looks like this:

{
  "role": "WebArea",
  "name": "Login",
  "children": [
    { "role": "heading", "level": 1, "name": "Sign in" },
    { "role": "textbox", "name": "Username" },
    { "role": "textbox", "name": "Password" },
    { "role": "checkbox", "name": "Remember me", "checked": false },
    { "role": "button", "name": "Sign in" }
  ]
}

Every node has a role. Nodes with an explicit or text-derived label also have a name. Missing names are the first thing to look for — a textbox with no name is an unlabeled field, a direct WCAG failure.

How do I assert every button has an accessible name?

Walk the tree, collect the interactive nodes, and fail the test if any button or link has an empty name. This one assertion catches the single most common accessibility bug: the icon-only or unlabeled control.

The helper below recursively flattens the tree, then filters for controls that must be named:

const { browser } = require('vibium/sync')
 
function flatten(node, out = []) {
  out.push(node)
  for (const child of node.children || []) flatten(child, out)
  return out
}
 
const bro = browser.launch()
const vibe = bro.page()
vibe.go('https://example.com')
 
const nodes = flatten(vibe.a11yTree())
const mustHaveName = ['button', 'link', 'textbox', 'checkbox']
 
const unnamed = nodes.filter(
  (n) => mustHaveName.includes(n.role) && !n.name
)
 
if (unnamed.length > 0) {
  console.error('Accessibility failures — controls with no name:')
  for (const n of unnamed) console.error(`  ${n.role}`)
  throw new Error(`${unnamed.length} control(s) missing an accessible name`)
}
console.log('All interactive controls have accessible names.')
 
bro.close()

The same logic in Python, ready to drop into pytest:

from vibium import browser_sync as browser
 
def flatten(node, out=None):
    out = out if out is not None else []
    out.append(node)
    for child in node.get("children", []):
        flatten(child, out)
    return out
 
vibe = browser.launch()
vibe.go("https://example.com")
 
nodes = flatten(vibe.a11y_tree())
must_have_name = {"button", "link", "textbox", "checkbox"}
unnamed = [n for n in nodes if n["role"] in must_have_name and not n.get("name")]
 
vibe.quit()
 
assert not unnamed, f"{len(unnamed)} control(s) missing an accessible name"

Because Vibium auto-waits for the page to load before you read the tree, the snapshot is complete rather than half-rendered. You are asserting against the finished page a real user would experience.

How do I scope the check to one region?

Pass a CSS selector to a11yTree({ root: '…' }) to inspect just one section of the page. On large pages the full tree is noisy, so scoping to the component under test keeps assertions focused and fast.

// Only inspect the navigation region
const navTree = vibe.a11yTree({ root: 'nav' })
 
const links = flatten(navTree).filter((n) => n.role === 'link')
const brokenLinks = links.filter((n) => !n.name)
 
if (brokenLinks.length) {
  throw new Error(`${brokenLinks.length} nav link(s) have no accessible name`)
}

Scoping is ideal for component-level tests: check a modal, a menu, or a form in isolation without unrelated page elements tripping the assertion. It mirrors how you would write a focused unit test rather than a full-page audit.

By default a11yTree() hides generic container nodes (plain divs and spans) so the output stays readable. If you are debugging why an element is missing, pass everything: true to see every node including generics — a control that shows up as generic instead of button usually means the page is missing semantic HTML or an ARIA role.

How do I verify ARIA states like expanded and checked?

Read the state flags on the relevant node and assert they reflect reality after an interaction. A menu that never reports expanded: true, or a toggle stuck at checked: false, is broken for screen-reader users even if it looks correct.

This test opens a disclosure menu and confirms the accessibility tree reports the new state, not just the visual change:

const { browser } = require('vibium/sync')
 
function findByRole(node, role) {
  if (node.role === role) return node
  for (const child of node.children || []) {
    const found = findByRole(child, role)
    if (found) return found
  }
  return null
}
 
const bro = browser.launch()
const vibe = bro.page()
vibe.go('https://example.com/menu')
 
// Before: the menu button should report collapsed
let btn = findByRole(vibe.a11yTree(), 'button')
if (btn.expanded !== false) {
  throw new Error('Menu should start collapsed (expanded=false)')
}
 
// Open it, then re-read the tree
vibe.find({ role: 'button', name: btn.name }).click()
 
btn = findByRole(vibe.a11yTree(), 'button')
if (btn.expanded !== true) {
  throw new Error('Menu did not report expanded=true after opening')
}
console.log('Menu correctly reports its expanded state.')
 
bro.close()

The pattern is always the same: snapshot the tree, act, snapshot again, and assert the state flag changed. This catches the common bug where JavaScript toggles a CSS class to show a panel but forgets to update aria-expanded, leaving assistive technology out of sync with what sighted users see.

State fields worth asserting include checked for checkboxes and radios, pressed for toggle buttons, expanded for disclosures and menus, selected for tabs and options, and disabled for controls that should be inert.

How do I check images have alt text and forms have labels?

Missing image alt text and unlabeled form fields are two of the highest-frequency WCAG failures, and both surface as a missing name in the tree. Filter for img and input roles and require a name.

from vibium import browser_sync as browser
 
def flatten(node, out=None):
    out = out if out is not None else []
    out.append(node)
    for child in node.get("children", []):
        flatten(child, out)
    return out
 
vibe = browser.launch()
vibe.go("https://example.com")
nodes = flatten(vibe.a11y_tree())
vibe.quit()
 
# Images must have alt text -> a name in the tree
images_no_alt = [n for n in nodes if n["role"] == "image" and not n.get("name")]
 
# Text inputs must be labelled
inputs_no_label = [n for n in nodes if n["role"] == "textbox" and not n.get("name")]
 
problems = []
if images_no_alt:
    problems.append(f"{len(images_no_alt)} image(s) missing alt text")
if inputs_no_label:
    problems.append(f"{len(inputs_no_label)} input(s) missing a label")
 
assert not problems, "; ".join(problems)

For a decorative image that should be hidden from assistive technology, the correct markup is alt="" plus aria-hidden="true", which removes the node from the tree entirely — so it will not appear in your image filter at all. That is the intended behavior: only meaningful images should carry a name.

How do I test the page the way a screen-reader user navigates it?

Drive the page with Vibium's semantic selectors — role plus name — instead of CSS. When your test can locate and operate a control the same way a screen reader identifies it, you have proven that control is reachable by name and role, which is the essence of an accessible interface.

Vibium's find() accepts semantic options that mirror the tree. role matches the ARIA role, name matches the accessible name (whether it comes from aria-label, a linked <label>, or text content), and there are focused variants like label, text, placeholder, and alt:

// Locate and operate controls by what a screen reader announces
vibe.find({ role: 'textbox', label: 'Email' }).type('user@example.com')
vibe.find({ role: 'checkbox', label: 'Remember me' }).click()
vibe.find({ role: 'button', name: 'Sign in' }).click()

The Python form is identical in spirit:

vibe.find(role="textbox", label="Email").type("user@example.com")
vibe.find(role="button", name="Sign in").click()

If any of those find() calls fails to locate an element, that is itself a finding: the control cannot be identified by its accessible name, so a screen-reader user could not reliably operate it either. Writing your happy-path tests this way makes them double as accessibility assertions.

One mapping gotcha to remember: label only matches explicit labelling (aria-label, aria-labelledby, <label for>). For a button or link whose name comes from visible text, use text instead. Getting this right is the same discipline that makes a page accessible — the label a user hears must actually exist in the markup.

What accessibility issues can Vibium catch, and what does it miss?

Vibium catches structural and semantic issues that live in the accessibility tree, but it does not measure anything visual like contrast. Use this table to decide when the tree is enough and when you need a dedicated tool.

IssueDetectable with a11yTree()?How
Unlabeled button or linkYesNode has role but empty name
Form input with no labelYestextbox node missing name
Image missing alt textYesimage node missing name
Wrong or missing ARIA roleYesNode reports generic or wrong role
Broken aria-expanded / checked stateYesState flag does not match after action
Non-semantic clickable <div>YesAppears as generic, not button
Color-contrast ratioNoUse axe-core or a contrast checker
Focus order and keyboard trapsPartialTest with keyboard events; not from the tree alone
Full WCAG 2.2 rule coverageNoRun axe-core plus a manual audit
Screen-reader announcement wordingApproximateClose via name, but verify with NVDA/VoiceOver

The honest positioning: Vibium is the fast, scriptable first line of defense that lives inside your existing test suite and fails the build on regressions. It is complementary to, not a replacement for, axe-core and manual testing with real assistive technology.

Vibium vs a dedicated accessibility tool

Both approaches belong in a mature pipeline; they solve different halves of the problem. Here is where each fits.

Vibium a11yTree()Dedicated a11y engine (e.g. axe-core)
SetupSingle binary, auto-downloads ChromeInject a script or use a plugin
Rule coverageAssert names, roles, states yourselfHundreds of prebuilt WCAG rules
Color contrastNot coveredCovered
IntegrationSame script that drives your appSeparate scan step
Custom assertionsFully programmableRule-based, less bespoke
Best forRegression checks on key flows in CIBroad automated WCAG coverage

When to choose Vibium: you want accessibility assertions woven directly into functional tests — for example, confirming the checkout button is always named and enabled — with zero extra tooling in CI. When to add axe-core: you need broad, standardized WCAG rule coverage and contrast checks across many pages. The strongest setup runs both: axe-core for breadth, Vibium for targeted, stateful assertions on the flows that matter most.

If you already write Vibium tests, adding a11y checks is nearly free because you reuse the same find() and page objects you have. See the find-element command reference for the semantic selectors that pair naturally with the accessibility tree.

How do I run accessibility checks in CI?

Run Vibium headless, fetch the tree for each critical page, and exit non-zero when a required element is unnamed. Because Vibium is one binary that fetches its own Chrome, there is no driver or browser install step in the pipeline.

from vibium import browser_sync as browser
 
PAGES = ["https://example.com/", "https://example.com/checkout"]
 
def flatten(node, out=None):
    out = out if out is not None else []
    out.append(node)
    for child in node.get("children", []):
        flatten(child, out)
    return out
 
def audit(url):
    vibe = browser.launch(headless=True)
    vibe.go(url)
    nodes = flatten(vibe.a11y_tree())
    vibe.quit()
    controls = {"button", "link", "textbox"}
    return [n["role"] for n in nodes if n["role"] in controls and not n.get("name")]
 
failures = {url: audit(url) for url in PAGES if audit(url)}
assert not failures, f"Unnamed controls found on: {list(failures)}"
print("Accessibility smoke test passed on all pages.")

Wire this into your test runner (pytest, Jest, or a plain script) and it becomes a gate: a merge that ships an unlabeled button turns the build red. That is the whole point — accessibility regressions get caught the same way functional regressions do, before they reach users. For the headless flags and server tips, see how to run Vibium on a server.

Tips for reliable accessibility tests

  • Assert on role and name together — a node can have the right role but a missing name, which is the failure you care about most.
  • Scope with root when testing a component so unrelated page elements do not create noise or false passes.
  • Re-read the tree after every state change — never assume a click updated aria-expanded; verify it did.
  • Treat a generic role as a red flag — it usually means a clickable element is a plain <div> that should be a real button or link.
  • Pair with axe-core and a real screen reader — the tree covers names, roles, and states, but contrast and lived screen-reader experience need dedicated tools.
  • Keep decorative images out of the tree with alt="" and aria-hidden="true" so only meaningful images are asserted on.

Next steps

Frequently asked questions

How do I do accessibility testing with Vibium?

Call a11yTree() to get the page's accessibility tree, then assert that interactive elements have the right role and an accessible name. Vibium exposes the same tree assistive technology reads, so you can catch unlabeled buttons, missing form labels, and broken states directly in a normal test run.

Can Vibium replace axe-core or a full WCAG audit?

No. Vibium surfaces the accessibility tree so you can assert on names, roles, and states, which catches a large class of real defects. It does not compute color-contrast ratios or run the full WCAG rule set, so pair it with axe-core or a manual audit for complete coverage.

What accessibility issues can the a11y tree catch?

The tree reveals unlabeled buttons and icons, form inputs with no accessible name, images missing alt text, wrong or missing ARIA roles, and incorrect states like a collapsed menu that never reports expanded. These map to some of the most common WCAG failures on real sites.

Does Vibium test what screen readers actually announce?

Vibium reads Chrome's computed accessibility tree, which is the same structure screen readers consume. Asserting on the name and role of a node is close to what a user hears, though it is not a substitute for testing with an actual screen reader like NVDA or VoiceOver.

How do I run accessibility checks in CI with Vibium?

Run Vibium headless with browser.launch(headless=True), fetch the a11y tree for each key page, and fail the build when a required element is missing its name or role. Because Vibium ships as a single binary that auto-downloads Chrome, there is no driver to manage in the pipeline.

What is an accessible name in the accessibility tree?

An accessible name is the label assistive technology announces for an element. It comes from aria-label, aria-labelledby, a linked label element, alt text on images, or visible text content. If a control has no accessible name, screen-reader users cannot tell what it does.

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

Related guides