Your browser already knows what every element is

When Chrome renders a web page, it doesn't just build the DOM. It builds a second tree in parallel: the accessibility tree. This tree exists so that screen readers like VoiceOver, JAWS, and NVDA can describe the page to people who can't see it. Voice control systems use it to let people speak commands like "click Sign In." Switch control hardware uses it to let people navigate with a single button.

The accessibility tree (AX tree) is Chrome's semantic representation of the page. It strips away everything presentational, everything structural, everything that exists only for layout or styling, and keeps only what matters for interaction. A <button class="btn-primary-lg mt-4 auth-submit" data-v-a3f2b1>Sign In</button> in the DOM becomes a single AX node: role button, name Sign In. The 47 characters of class names, the framework-specific hash, the utility classes from Tailwind, none of that exists in the AX tree. The tree knows one thing: there's a button called Sign In.

This isn't a simplified view. It's a computed view. Chrome's Blink rendering engine runs the full W3C Accessible Name and Description Computation algorithm on every element. It resolves aria-labelledby references across the DOM. It applies implicit ARIA role mappings based on element type and context. It follows aria-owns relationships to restructure the tree differently from the DOM's parent-child hierarchy. It evaluates aria-hidden, role="presentation", and CSS display: none to prune elements that shouldn't be exposed. The result is a tree that represents what users experience, computed by the browser engine that's responsible for making that experience work.

Every testing tool targets the DOM. CSS selectors, XPath expressions, data-testid attributes, jQuery selectors, they all query the same structure: the build artifact. The DOM is how the page was built. The AX tree is what the page is.

That distinction is the thesis of this article. When you target the DOM, your tests are coupled to implementation. When you target the AX tree, your tests are coupled to behavior. Everything else follows from that.

Why DOM selectors are inherently fragile

A CSS selector like .auth-submit targets one thing: a class name. That class name is an implementation detail decided by a developer at a specific moment for a specific reason. It could change tomorrow for any of these reasons:

  • A Tailwind migration replaces custom classes with utility classes
  • A CSS Modules or CSS-in-JS tool generates hashed class names
  • A component library upgrade renames its internal classes
  • A designer decides .login-cta is a better name than .auth-submit
  • A framework migration (React to Vue, Angular to Svelte) changes the component structure
  • A build tool update changes how scoped styles are compiled

None of these changes affect what the user sees. The button still says Sign In. It still logs you in when you click it. But the CSS selector is dead and the test fails.

XPath is worse. A path like /html/body/div[3]/main/form/div[2]/button breaks when any ancestor element is added, removed, or reordered. Insert a toast notification container above the form and the div[3] becomes div[4]. The test fails because an unrelated element was added to a different part of the page.

data-testid attributes are more stable because they're explicitly maintained for testing. But they require discipline. Every interactive element needs a unique test ID. Every refactored component needs to carry its test IDs forward. Every new element needs a new ID. And they create a shadow naming system that has no connection to what the user sees. data-testid="submit-btn" tells you nothing about whether the button says "Submit," "Save," or "Continue." The test ID can drift from the actual UI without any automated check catching it.

The fundamental problem is that DOM selectors target how the page was built. CSS classes, element nesting, attribute values, these are construction details. They change when construction techniques change, even when the building itself looks and functions identically.

What is the accessibility tree, precisely?

The AX tree is a parallel representation of the page that Chrome maintains in its browser process. It's built by the Blink rendering engine as part of the rendering pipeline, alongside layout computation and paint. Every time the DOM changes, Chrome updates the AX tree to reflect the new semantic state.

Here's what Chrome does to build it, documented in Chromium's own accessibility architecture docs:

Step 1: Role determination. For every DOM element, Blink determines the accessibility role. Native HTML semantics come first: a <button> has role button, an <a href> has role link, an <input type="text"> has role textbox. If a valid ARIA role is specified (role="dialog"), it overrides the native role. If the ARIA role is invalid or unsupported, Blink falls back to the native mapping.

Step 2: Name computation. Chrome runs the full Accessible Name and Description Computation algorithm (W3C AccName spec). This is a multi-step process with a defined priority order: aria-labelledby (resolved by following ID references and concatenating their text content), then aria-label, then native naming rules (like <label for="id">), then text content, then title attribute. The algorithm handles edge cases like recursive label references, hidden referents, and embedded controls within labels.

Step 3: Node creation. For every exposed element, Blink creates an AXNode containing the computed role, name, description, states (checked, expanded, disabled, pressed), value (for inputs), and relationships (aria-owns, aria-controls, aria-describedby).

Step 4: Tree pruning. Elements that have no semantic or interactive value are excluded. A <div> that only serves as a flex container, a <span> that only changes text color, a decorative image with an empty alt attribute, these don't appear in the AX tree. The tree keeps what matters and discards the rest.

Step 5: Position and bounds. Chrome computes the on-screen position and size of each AX node based on layout, CSS transforms, scrolling offset, and compositing layers. This is the same bounding box that assistive technologies use to highlight elements.

The result is significantly smaller than the DOM. A React app built with Material UI might have 2,000 DOM nodes: wrapper divs for layout, span elements for styling, invisible containers for portals, CSS-in-JS artifacts. The AX tree for the same page might have 200 nodes: the buttons, links, text fields, headings, landmarks, and text content that users actually interact with. That's a 10:1 reduction, and it's typical for component-heavy frameworks.

This isn't just a filtered DOM. The AX tree can have a different structure than the DOM. aria-owns reparents elements, making a dropdown menu that lives at the bottom of the DOM appear as a child of its trigger button in the AX tree. Presentational tables flatten their structure. Complex widgets like comboboxes expand into multiple AX nodes (the input, the popup, individual options) even if the DOM structure is simpler. The AX tree represents the experience, which sometimes has a different shape than the implementation.

How does Playwright's getByRole actually work?

Playwright recommends getByRole() as the primary selector strategy. The documentation says to "prioritize user-facing attributes and explicit contracts" over DOM structure. This is good advice. The implementation, however, doesn't follow its own recommendation.

Playwright's getByRole() does not query the browser's accessibility tree.

Here's what happens when you call page.getByRole('button', { name: 'Sign In' }):

  1. Playwright's FrameSelector injects a script called roleSelectorEngine.ts into the page's JavaScript context
  2. That script calls querySelectorAll('*') on the DOM, selecting every single element on the page
  3. For each element, the script computes the ARIA role by applying WAI-ARIA specification rules to DOM attributes, implicit HTML semantics, and element context
  4. For elements matching the requested role, it computes the accessible name using its own implementation of the AccName algorithm
  5. It returns elements where both the computed role and the computed name match the query

This is a JavaScript reimplementation of what Chrome's Blink engine already computed natively. It's running in web page context, not in the browser's privileged process where the real AX tree lives. It's iterating every DOM element and recomputing accessibility semantics from scratch on every query.

The source code is in packages/playwright-core/src/server/injected/roleSelectorEngine.ts. The core function queryRole calls querySelectorAll('*') and walks every element. This is publicly verifiable.

Three consequences follow from this architecture:

Performance. Every getByRole() call walks the entire DOM and computes roles for every element. SerpApi benchmarked this: getByRole is 1.5x slower than CSS selectors. In their test, 100 getByRole iterations took 677.5ms vs 497.3ms for CSS. For a 10-step test, the difference is negligible. For a 5,000-assertion suite running in CI, those milliseconds compound.

Accuracy gaps. The JavaScript reimplementation has to replicate every browser-specific accessibility heuristic. Edge cases in role computation, implicit roles, presentational role inheritance through shadow DOM, aria-owns reparenting, these can diverge from what the browser actually exposes to assistive technologies. Playwright's GitHub issue #34348 documents one such gap: getByRole() doesn't resolve aria-owns and aria-controls relationships as children. An accessible combobox pattern where the listbox lives outside the combobox's DOM container but is connected via aria-owns won't be found by chaining getByRole locators. The real AX tree resolves these relationships correctly because assistive technologies depend on them.

The real AX tree was removed. Playwright used to have a Page#accessibility API that called CDP's Accessibility.getFullAXTree to retrieve the actual browser accessibility tree. In v1.57, Playwright removed this API entirely. The deprecation notice said it was "incomplete and hard to use" and recommended Axe for accessibility testing. This means the only path to the real AX tree in Playwright was eliminated. What remains is exclusively the DOM-injected simulation.

To be clear: Playwright's getByRole() is still better than CSS selectors for testing stability. Targeting roles and names is more resilient than targeting class names. But calling it "accessibility-first testing" obscures what's actually happening. It's DOM-first testing with accessibility-inspired naming conventions. The browser's actual accessibility tree is never consulted.

What changes when you query the real AX tree

Chrome DevTools Protocol exposes two methods in its Accessibility domain that operate on the real accessibility tree:

Accessibility.getFullAXTree returns the entire accessibility tree for a document frame. Every node, every role, every name, every state. This is the tree that screen readers consume, serialized as JSON over a WebSocket connection.

Accessibility.queryAXTree searches the AX tree for nodes matching a role, an accessible name, or both. It doesn't touch the DOM. It doesn't inject JavaScript. It queries the tree that Chrome already computed and maintains in the browser process.

When PiperTest resolves the selector role:button:Sign In, here's what happens at the CDP level:

  1. If this is the first AX query on the current page, PiperTest calls Accessibility.getFullAXTree to prime the tree. Chrome builds the AX tree lazily, and without this initial call, subsequent queries can return empty results. This is a Chrome 148+ behavior
  2. PiperTest calls DOM.getDocument to get the document root's backendNodeId. Chrome 148+ requires explicit scoping to the document root for queryAXTree to return meaningful results
  3. PiperTest calls Accessibility.queryAXTree with parameters {role: "button", accessibleName: "Sign In", backendNodeId: <root>}
  4. Chrome returns matching AX nodes from its native tree, each with a backendDOMNodeId that links back to the DOM element
  5. PiperTest calls DOM.resolveNode to get a runtime object ID for the matched element, then executes the action on it

No JavaScript injection. No DOM iteration. No role recomputation. Three CDP calls over an existing WebSocket connection. The browser already did the hard work of computing accessibility semantics. PiperTest just reads the result.

This means PiperTest's selectors agree with what assistive technologies see. If a button doesn't appear in the AX tree, PiperTest can't find it, and neither can a screen reader. If a developer removes an aria-label or adds aria-hidden="true", the AX node disappears and the test fails. The test failure tells you something real: you broke accessibility. A DOM-based selector would still find the element because the DOM node is still there, even though it's now invisible to a blind user.

PiperTest's selector format

PiperTest uses five selector types, four of which resolve through the AX tree:

role:button:Sign In          AX tree: role + accessible name
label:Email                  AX tree: accessible name (interactive elements only)
text:Welcome to the app      AX tree: accessible name (any element)
testid:submit-btn            DOM: data-testid attribute
css:.custom-widget           DOM: CSS selector (escape hatch)

The hierarchy is deliberate. role selectors are the strongest because they encode both what the element is (its role) and what it's called (its name). label selectors target form controls by their visible label text. text selectors find any element by its accessible name. testid and css are escape hatches for elements that don't have adequate accessibility markup, and they resolve through the DOM because the AX tree doesn't index test IDs or CSS classes.

Hierarchical scoping handles ambiguity:

role:form:Login > role:button:Submit

This finds the Submit button that's inside the Login form, even if another Submit button exists elsewhere on the page. The ancestor is resolved first (find the Login form in the AX tree), then the descendant is searched within that subtree. Both lookups use the AX tree, so the scoping follows the accessibility hierarchy, not the DOM hierarchy.

For label selectors, PiperTest adds an important constraint: it only returns interactive form controls (textbox, combobox, checkbox, radio, slider, switch, listbox, and similar roles). If you write label:Email, PiperTest won't return the <label> element itself, it returns the input that the label describes. This matches user intent: when someone says "the Email field," they mean the input, not the label text.

Selector stability across real-world changes

The argument for AX-native selectors isn't theoretical. It maps directly to the changes that break tests in practice.

CSS refactors. Renaming .btn-primary to .button-main or migrating from custom CSS to Tailwind changes every CSS selector on the page. The AX tree doesn't change. Every button still has its role and name. Zero tests break.

Component library migrations. Swapping Material UI for Radix, or Bootstrap for Shadcn, replaces the DOM structure of every component. Wrapper divs change, class names change, data attributes change. The AX tree mostly stays the same because the new components implement the same ARIA patterns. A dialog is still a dialog. A tab panel is still a tab panel. Tests that target roles and names keep working.

Framework migrations. Moving from React to Vue to Svelte changes the entire rendering pipeline. React's reconciler produces different DOM than Vue's template compiler, which produces different DOM than Svelte's compiled output. The AX tree is framework-agnostic. If the migrated app renders the same buttons, links, and form fields with the same labels, the AX tree is identical and every test passes.

Build tool changes. Switching from Webpack to Vite, or enabling CSS Modules, or changing a PostCSS configuration, these alter how class names are generated and bundled. They change the DOM's class attributes. They don't change the AX tree.

Layout restructuring. Moving a sidebar from left to right, wrapping content in a new container for a grid layout, adding a sticky header, all of these change DOM structure and potentially break XPath and structural CSS selectors. The AX tree describes content semantics, not visual layout. A navigation landmark is a navigation landmark regardless of where it appears in the viewport.

The one category of changes that does break AX selectors is changes to accessible names. If a button is renamed from "Submit" to "Save Changes," the selector role:button:Submit fails. But this is a meaningful change. The user-visible label changed. The test should notice. And when PiperTest's self-healing encounters this, it finds "Save Changes" through fuzzy matching (Levenshtein distance), heals the selector, and persists the mapping. The test keeps running. More on this connection in the next section.

The self-healing connection

AX-native selectors and self-healing aren't separate features. They reinforce each other because the AX tree is structured data with exactly two identifying properties per node: role and name.

When a CSS selector breaks, the test runner has almost nothing to work with. .auth-submit doesn't exist anymore. What should the runner try instead? There are potentially thousands of elements on the page with various class names. The search space is enormous and the signal is weak.

When an AX selector breaks, the search space is tiny and the signal is strong. role:button:Submit failed. The runner queries all buttons on the page (maybe 8 to 12 on a typical page). It compares each button's accessible name to "Submit" using normalized containment and Levenshtein distance. "Save" has distance 4. "Submit Form" contains "Submit" with a length ratio above 0.4. "Cancel" has distance 6 (above threshold, rejected). The right candidate usually jumps out because accessible names are short, descriptive, and meaningfully different from each other.

Fuzzy matching works well on AX selectors for three reasons:

  1. Small candidate pool. A page might have 2,000 DOM elements but only 10 buttons. Searching 10 candidates is trivial
  2. Meaningful names. Accessible names are user-facing labels. They're written to be distinguishable because users need to tell them apart. "Sign In" and "Sign Up" are intentionally different. ".btn-1" and ".btn-2" are not
  3. Typed search. Role-based searching narrows the candidates before name matching begins. The runner doesn't compare "Submit" against every string on the page. It compares "Submit" against the names of other buttons. The type constraint eliminates false positives

This is why PiperTest's self-healing achieves high confidence in 5-15ms with no AI and no cloud. The AX tree gives the healing algorithm exactly what it needs: a small set of well-typed, meaningfully-named candidates to search. DOM-based healing tools need ML models and cloud infrastructure because the DOM's signal-to-noise ratio is so much worse.

How the AX tree handles things the DOM doesn't

The AX tree isn't just a filtered view of the DOM. It computes relationships and resolves ambiguities that pure DOM analysis misses.

aria-owns reparenting. A dropdown menu might live at the bottom of the DOM (inside a portal container) but be logically owned by a button at the top of the page. aria-owns declares this relationship. The AX tree reparents the menu as a child of the button. A hierarchical AX selector like role:button:Menu > role:menuitem:Settings works correctly because the AX tree follows the ownership relationship. A DOM-based selector would need to know the portal implementation to find the connection.

aria-controls and aria-describedby relationships. These ARIA attributes create semantic connections between elements that aren't parent-child in the DOM. A tab controls a tab panel. An input is described by a help text paragraph. The AX tree exposes these as named relationships. Testing tools that query the real AX tree can follow them. DOM-based tools can't, because these relationships don't exist in the DOM structure.

Implicit role resolution. A <nav> element has the implicit role navigation. A <main> has role main. An <input type="email"> has role textbox with additional properties. These implicit roles are part of the HTML specification and are computed by the browser's rendering engine. The AX tree contains the resolved roles. DOM-based role computation has to replicate these rules in JavaScript, and any rule it gets wrong diverges from what assistive technologies see.

Shadow DOM traversal. Chrome's AX tree includes content inside open shadow roots natively. The accessibility tree doesn't care about shadow DOM boundaries because screen readers don't care about them. A button inside a web component's shadow DOM appears in the AX tree with its role and name, findable by any AX query. DOM-based selectors need explicit shadow DOM piercing (>>> in CSS, shadowRoot.querySelector in JavaScript) to reach inside shadow boundaries.

A comparison table that's actually useful

The table clarifies a spectrum. CSS and XPath target implementation. data-testid creates a stable parallel namespace but requires maintenance. Playwright's getByRole uses accessibility-inspired naming through DOM injection. Cypress's data-cy is the same idea as data-testid with Cypress-specific conventions. PiperTest queries the actual AX tree. Each step moves closer to targeting what users experience instead of how the page was built.

The Chrome 148 story

Building on the real AX tree means dealing with Chrome's evolving implementation directly. Chrome 148 introduced several changes to the Accessibility domain that broke our initial implementation and taught us things about the AX tree that aren't documented anywhere.

AX node ID type coercion. The CDP specification defines AXNodeId as a string type. Chrome 148 started returning node IDs as integers in getFullAXTree responses. Parent IDs, child IDs, same thing. Our axNodeId() helper now handles String, Int, and NSNumber coercion on every node. This affects tree rendering, diffing, path extraction, and ancestor traversal.

Frame-scoped queries. getFullAXTree now requires an explicit frameId parameter. Without it, Chrome returns only the root RootWebArea node instead of the full tree. We store the main frame ID on page attachment and pass it with every full tree request.

Document-root scoping. queryAXTree now requires explicit backendNodeId scoping to the document root. Without it, queries return empty results even when matching nodes exist. Every AX query in PiperTest now starts with DOM.getDocument to get the root node ID, then passes it as a scope parameter.

Lazy AX tree construction. Chrome builds the accessibility tree lazily. The first queryAXTree call on a new page can return empty results because the tree hasn't been constructed yet. PiperTest now primes the tree with a getFullAXTree call on the first AX query per page, then sets a flag to avoid repeating the prime on subsequent queries.

These aren't Chrome bugs. They're security and performance improvements. Frame scoping prevents cross-origin AX tree leakage. Lazy construction avoids the cost of building the AX tree for pages where no accessibility client is connected. But they're not documented in the CDP specification, and they broke every CDP-based tool that used the Accessibility domain.

Most tools weren't affected because most tools don't use the Accessibility domain. They use DOM.querySelector with CSS selectors. We hit these issues precisely because we chose to query the real AX tree, and we fixed them because there's no alternative that gives us what we need.

Honest limitations

AX-native testing isn't perfect. These are the real constraints.

Chrome only. The CDP Accessibility domain is a Chrome-specific API. Firefox has its own accessibility implementation but doesn't expose it through the same protocol. Safari uses a completely different accessibility architecture (NSAccessibility on macOS). PiperTest's AX-native selectors work in Chrome, Chrome Dev, and Chrome Canary. They don't work in Firefox or Safari. For cross-browser testing, you export PiperTest sessions to Playwright code (which maps AX selectors to getByRole/getByLabel/getByText) and run that in Playwright's multi-browser engine.

Closed shadow DOM. Chrome's AX tree does include content inside open shadow roots. But web components using closed shadow roots (attachShadow({ mode: 'closed' })) are opaque. The browser's AX tree may not fully expose their internal structure. If your app uses closed shadow roots extensively, some elements won't be reachable through AX queries.

Poorly labeled elements. If a button has no accessible name, no aria-label, no visible text content, and no associated label, the AX tree has nothing useful to expose. The node exists with role button and an empty name. You can't write a meaningful role:button: selector for it because there's nothing to match on. This is actually a useful signal: the element is inaccessible to screen readers too. The right fix isn't a better selector, it's adding an accessible name.

Canvas and WebGL content. Content rendered on a <canvas> element doesn't appear in the AX tree unless the application explicitly provides fallback content or ARIA markup. Games, data visualizations, and custom renderers that draw directly to canvas are invisible to AX queries.

Rapidly changing names. If an element's accessible name changes frequently (a counter updating every second, a timer display, a live stock price), AX selectors that include the name will break on timing variations. For these elements, targeting by role alone (role:timer) or by hierarchical context (role:region:Stock Price > role:text) is more stable than including the fluctuating value in the selector.

Performance on extremely large pages. The AX tree prime (getFullAXTree) on the first query per page takes time proportional to the page's semantic complexity. For a page with 10,000+ AX nodes (rare, but possible on data-heavy dashboards), the initial prime can take 200-500ms. Subsequent queryAXTree calls are fast because the tree is already built. Pages with normal complexity (100-500 AX nodes) prime in under 50ms.

What this means for your testing strategy

The argument here isn't "stop using Playwright" or "AX selectors are always better." It's more specific than that.

If test maintenance is your biggest cost, and industry surveys consistently show it is, the selector strategy is the highest-leverage change you can make. Moving from CSS selectors to AX-native selectors eliminates the entire category of breakage caused by implementation changes that don't affect behavior. That's the majority of E2E test failures.

If you care about accessibility (for compliance, for users, or both), testing against the real AX tree gives you accessibility regression detection for free. Every test run implicitly verifies that the tested elements are reachable by assistive technologies. You don't need a separate accessibility audit pass for the flows you're already testing.

If you use AI for test generation, the AX tree is a dramatically better representation for language models to consume and generate against. A pruned AX tree with 200 nodes, each having a clear role and name, fits in a small context window and is easy for a model to reason about. A full DOM with 2,000 nodes of nested divs, hashed class names, and framework artifacts is noisy, expensive, and leads to worse test generation.

If you need cross-browser testing, you'll still need Playwright or a similar tool for the multi-browser verification pass. PiperTest exports to Playwright code with proper getByRole/getByLabel/getByText mappings. Author against the real AX tree, iterate with self-healing, export for CI. The AX-native selectors carry their stability through the export because Playwright's getByRole, while a DOM simulation, still targets roles and names rather than CSS classes.

Try it

Download ToolPiper from the Mac App Store. Open Chrome. Click Connect. Click Record. Interact with your app. Stop recording. Every captured step uses AX-native selectors by default. The selector quality hierarchy automatically upgrades weak selectors during recording.

Then change something in your app's CSS. Rename a class. Switch a component library. Run the test. It passes because the AX tree didn't change.

Then rename a button label. Run the test. Watch the self-healing find the renamed button in under 15ms, execute the action, and persist the healed selector. The step shows status "healed" with the original selector, the healed selector, and the confidence score.

That's the difference between testing against how your page was built and testing against what your page is.

This is part of the AI-powered testing series. For the CDP engine architecture, see AX-Native Browser Automation. For how self-healing works in detail, see Self-Healing Test Selectors. For the full framework comparison, see Playwright vs Cypress vs PiperTest.