WebView in uiautomator dump: what your HTML actually turns into
adb shell uiautomator dump is the automation workhorse: one command, an XML file, “here’s what’s on screen”. But point it at an app that shows web content in a WebView and the relationship between your HTML and that XML gets strange. This post is a hands-on study of exactly how HTML becomes accessibility-tree XML, verified live against a physical device.
TL;DR
uiautomator dumpserialises the Android accessibility tree (AccessibilityNodeInfo) to XML — the same tree Appium’s UiAutomator2 driver and native automation read.- The WebView’s part of that tree is built lazily — when no accessibility service is bound. After a cold start, the first dump shows only a bare
<node class="android.webkit.WebView">; the second dump has your whole page. This is why “content isn’t in the XML until I run the dump again” happens — and why a modal or late-appearing component seems to “sometimes” vanish. A bound accessibility service (a real screen-reader client) or--force-renderer-accessibility— a WebView command-line flag (see Gotcha 1) — makes the first dump complete. - The mapping (on the WebView version tested):
text→text;aria-label→text(notcontent-desc, on this build); roles→platform classes (Button,CheckBox,EditText,SeekBar,Image);sr-onlytext is present;aria-hiddenanddisplay:noneare absent; HTMLid→resource-id(verified on WebView 115, Chrome 151/153 and Vanadium 152; it regressed in Chrome 120–126 and Chromium fixed it in M127, 2024 — one residual caveat in Gotcha 3);data-testidnever appears. - Two myths, disproved: you do not need a debuggable app to read the dump (a release build dumps fine), and you cannot surface
data-testidinto the XML. - Bounds are device pixels, and the conversion factor depends on your device’s density (including a density override, if you have one set).
What uiautomator dump actually is
One command, three layers:
uiautomatorruns on the device as theshelluser and opens a connection to the accessibility framework (UiAutomation).- It grabs the root node of the active window and walks the
AccessibilityNodeInfotree — the same tree native automation, TalkBack and other accessibility services read. - AOSP’s
AccessibilityNodeInfoDumperserialises it to a flat<node …/>XML with a fixed attribute set:
| Attribute | Meaning |
|---|---|
text | The node’s primary text |
content-desc | Accessibility description |
resource-id | Android view id (see the id story below) |
class | The platform view class (android.widget.TextView, …) |
package | Owning app package |
checkable/checked/clickable/enabled/focusable/focused/scrollable/long-clickable/selected | State flags |
password | Text is a password (masked) |
bounds | Device-pixel rectangle, [left,top][right,bottom] |
NAF | ”Not Accessibility Friendly” — clickable but no text/description |
By default the dump covers only the active window (getRootInActiveWindow). uiautomator dump --windows switches to all windows and produces a different shape: <displays><display><window title="…" bounds="…" active="true">….
This matters because, on the native side, a WebView is just one view node — and everything else you see in the dump for a web page are accessibility nodes the WebView fabricates for your HTML. That fabricated subtree is what this post maps.
The pipeline, in one picture
Between your HTML and the XML there are three hops, and each one drops information:
flowchart LR
subgraph DOM["your HTML"]
A["<p>Hello</p> · <button>Send</button> · aria-label · data-testid"]
end
subgraph BLINK["Blink accessibility tree (AX)"]
B["AXNode: role · name · state"]
end
subgraph ANDROID["WebView a11y bridge → AccessibilityNodeInfo"]
C["class · text / content-desc · flags · bounds"]
end
subgraph XML["AccessibilityNodeInfoDumper"]
D["<node text=… class=… />"]
end
A -->|"1. only semantic properties survive;
data-* attributes die here"| B
B -->|"2. role → platform class + flags;
name → text / content-desc"| C
C -->|"3. fixed attribute projection +
NAF + visibility filtering"| D- DOM → Blink AX tree. Blink keeps an accessibility tree of semantic properties: role, name, states. Non-semantic attributes —
data-*,class, inline style — never enter it. Yourdata-testidis gone before step two even starts. - AX tree →
AccessibilityNodeInfo. The WebView’s accessibility bridge maps roles onto Android concepts:AXRole::BUTTON→isClickable()+className="android.widget.Button",AXRole::CHECK_BOX→isCheckable()/isChecked(),AXRole::TEXT_BOX/PASSWORD_FIELD→EditText+passwordflag. The accessible name (from text content,aria-label,alt, …) lands intextorcontent-desc— on the WebView build tested here,aria-labellanded intext. - NodeInfo → XML. The dumper projects onto its fixed attribute list, marks unlabeled-but-clickable nodes
NAF="true", and applies visibility checks. Bounds are emitted in raw device pixels.
The mapping table
The full dump is large, so instead of inlining it, here are two files you can open in a new tab:
Then the same dump, broken out one element at a time — HTML on the left, its dumped node on the right. All the fixture’s static cases appear below (the two dynamic ones — delayed content and the scroll region — are covered in Gotchas 1 and 2; the page headings around each fixture case aren’t cases themselves). The last three — cases 32–34, the sr-only/aria-hidden comparison — come from a separate screen-reader-enabled capture — they aren’t in the warm dump.xml; the worked example below walks through them.
Expand to see all 33 mapping cases
<p>
Hello world, plain text.
</p> <node
text="Hello world, plain text."
resource-id=""
class="android.widget.TextView"
package="com.example.androidapp"
content-desc=""
checkable="false"
checked="false"
clickable="false"
enabled="true"
focusable="false"
focused="false"
scrollable="false"
long-clickable="false"
password="false"
selected="false"
bounds="[29,415][1051,450]"
/> the heading role is not exposed by the dump: there is no heading attribute in its fixed attribute set (Appium can read a heading attribute on API 28+ where the platform sets it)
<h3>
Case Two Heading
</h3> <node
text="Case Two Heading"
resource-id=""
class="android.widget.TextView"
…
bounds="[29,588][1051,627]"
/> a clickable wrapper carries the link text as content-desc around a text child; the serialisation varies between runs (sometimes a plain, non-clickable TextView)
<a href="https://example.com">
A link to example.com
</a> <node
text=""
resource-id=""
class="android.view.View"
content-desc="A link to example.com"
clickable="true"
focusable="true"
bounds="[29,745][296,780]"
>
<node
text="A link to example.com"
class="android.widget.TextView"
…
/>
</node> <button type="button">
Send
</button> <node
text="Send"
resource-id=""
class="android.widget.Button"
…
clickable="true"
focusable="true"
…
bounds="[29,898][121,943]"
/> the label text is merged onto the checkbox node
<label>
<input type="checkbox" checked>
Accept terms
</label> <node
text="Accept terms"
resource-id=""
class="android.widget.CheckBox"
…
checkable="true"
checked="true"
clickable="true"
…
/> <label>
<input type="radio" name="r" checked>
Option A
</label> <node
text="Option A"
…
class="android.widget.RadioButton"
…
checkable="true"
checked="true"
…
/> the value becomes text
<input type="text" value="Type here" autocomplete="off"> <node
text="Type here"
…
class="android.widget.EditText"
…
clickable="true"
focusable="true"
…
/> the value is masked and password="true" is set
<input type="password" value="secret"> <node
text="••••••"
…
class="android.widget.EditText"
…
password="true"
…
/> <textarea rows="2">
Multiline text area content
</textarea> <node
text="Multiline text area content"
…
class="android.widget.EditText"
…
/> the selected option becomes text on a plain View
<select>
<option>
One
</option>
<option>
Two
</option>
</select> <node
text="One"
…
class="android.view.View"
…
clickable="true"
focusable="true"
…
/> alt maps to text
<img alt="A red rectangle"> <node
text="A red rectangle"
…
class="android.widget.Image"
…
/> no description, so the src (an inline SVG data-URI) is the fallback name — and only its tail, svg>, leaked into text (a quirk of inline data-URI images without alt)
<img src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='60' height='30'><rect width='60' height='30' fill='%2345a'/></svg>"> <node
text="svg>"
…
class="android.widget.Image"
…
/> the label became text and replaced the inner text; content-desc stays empty
<div role="img" aria-label="Company logo">
Logo placeholder
</div> <node
text="Company logo"
…
class="android.widget.Image"
…
/> the label wins
<div role="button" aria-label="Close dialog">
X
</div> <node
text="Close dialog"
…
class="android.widget.Button"
…
clickable="true"
…
/> <div role="button" tabindex="0">
Fake button div
</div> <node
text="Fake button div"
…
class="android.widget.Button"
…
clickable="true"
focusable="true"
…
/> <div role="checkbox" aria-checked="true" tabindex="0">
Custom checkbox
</div> <node
text="Custom checkbox"
…
class="android.widget.CheckBox"
…
checkable="true"
checked="true"
clickable="true"
…
/> the role is lost
<div role="heading" aria-level="2">
Custom heading role
</div> <node
text="Custom heading role"
…
class="android.widget.TextView"
…
/> present in the tree (correct WCAG behaviour)
<span class="sr-only">
Visually hidden but accessible text
</span> <node
text="Visually hidden but accessible text"
…
class="android.widget.TextView"
…
/> absent from the dump (correct)
<div aria-hidden="true">
This whole subtree is hidden from accessibility
</div> <!-- no node at all --> absent from the dump (correct)
<div style="display:none">
Display none, not in the tree
</div> <!-- no node at all --> maps to resource-id on current builds (WebView 115, Chrome 151/153, Vanadium 152 — all verified); note it regressed in Chrome 120–126 and Chromium fixed it in M127, see Gotcha 3
<div id="unique-element-id">
I have an HTML id
</div> <node
text="I have an HTML id"
resource-id="unique-element-id"
…
/> never appears; resource-id stays empty
<div data-testid="my-test-hook">
I have a data-testid
</div> <node
text="I have a data-testid"
resource-id=""
…
/> merged into a single text node
<p>
<span>
Left span,
</span>
<span>
right span.
</span>
</p> <node
text="Left span, right span."
…
/> each bullet is its own View with text="• "
<ul>
<li>
First item
</li>
<li>
Second item
</li>
</ul> <node
text="• "
…
class="android.view.View"
…
/>
<node
text="First item"
…
class="android.widget.TextView"
…
/>
<node
text="• "
…
class="android.view.View"
…
/>
<node
text="Second item"
…
class="android.widget.TextView"
…
/> plain View, not TextView
<table>
<tr>
<th>
Name
</th>
<th>
Value
</th>
</tr>
<tr>
<td>
alpha
</td>
<td>
1
</td>
</tr>
<tr>
<td>
beta
</td>
<td>
2
</td>
</tr>
</table> <node
text="Name"
…
class="android.view.View"
…
/>
<node
text="Value"
…
class="android.view.View"
…
/>
<node
text="alpha"
…
class="android.view.View"
…
/>
<node
text="1"
…
class="android.view.View"
…
/>
<node
text="beta"
…
class="android.view.View"
…
/>
<node
text="2"
…
class="android.view.View"
…
/> SeekBar with the value as text
<input type="range" min="0" max="10" value="5"> <node
text="5"
…
class="android.widget.SeekBar"
…
clickable="true"
focusable="true"
…
/> <details>
<summary>
Expand me
</summary>
Hidden details content.
</details> <node
text="Expand me"
…
class="android.view.View"
…
clickable="true"
focusable="true"
…
/> <div contenteditable="true">
Editable content
</div> <node
text="Editable content"
…
class="android.widget.EditText"
…
/> the role="dialog"/aria-modal/aria-label on the container produce nothing; the inner title, body and buttons appear as plain nodes
<div id="modal-box" role="dialog" aria-modal="true" aria-label="Example modal">
<h3>
Modal title
</h3>
<p>
Modal body text.
</p>
<button type="button" id="modal-close">
Close
</button>
</div> <node
text="Modal title"
…
class="android.widget.TextView"
…
/>
<node
text="Modal body text."
…
class="android.widget.TextView"
…
/>
<node
text="Close"
resource-id="modal-close"
…
class="android.widget.Button"
…
clickable="true"
…
/> the button below the fold stays in the tree, but with bounds="[0,0][0,0]"
<button type="button" id="open-modal">
Open modal
</button> <node
text="Open modal"
resource-id="open-modal"
…
class="android.widget.Button"
…
clickable="true"
…
bounds="[0,0][0,0]"
/> only the sr-only sentence appears; the aria-hidden breakdown (Contact:, James Roger, 23, emails) is absent — so 23 is unreachable
<div>
<span class="sr-only">Contact James Roger has 23 unread emails</span>
<div aria-hidden="true">
<span>Contact:</span><span>James Roger</span>
<span>23</span><span>emails</span>
</div>
</div> <node
text="Contact James Roger has 23 unread emails"
resource-id=""
class="android.widget.TextView"
…
bounds="[0,0][0,0]"
/> the <dl> becomes a ListView whose text is the aria-label sentence; every <dt>/<dd> keeps its own node — including 23. (But the aria-label on a generic <dl> is exposed in the tree only, not announced by screen readers — see the worked example)
<dl aria-label="Contact James Roger has 23 unread emails">
<dt>Contact</dt><dd>James Roger</dd>
<dt>Emails</dt><dd>23</dd>
</dl> <node
text="Contact James Roger has 23 unread emails"
resource-id=""
class="android.widget.ListView"
…
bounds="[0,0][0,0]"
>
<node
text="Contact"
resource-id=""
class="android.view.View"
…
bounds="[0,0][0,0]"
/>
<node
text="James Roger"
resource-id=""
class="android.view.View"
…
bounds="[0,0][0,0]"
/>
<node
text="Emails"
resource-id=""
class="android.view.View"
…
bounds="[0,0][0,0]"
/>
<node
text="23"
resource-id=""
class="android.view.View"
…
bounds="[0,0][0,0]"
/>
</node> the container keeps the aria-label sentence as its text, but the individual values collapse away — 23 is unreachable as a discrete value
<div role="group" aria-label="Contact James Roger has 23 unread emails">
<span>Contact:</span><span>James Roger</span>
<span>23</span><span>emails</span>
</div> <node
text="Contact James Roger has 23 unread emails"
resource-id=""
class="android.view.View"
…
bounds="[0,0][0,0]"
>
<node
text=""
resource-id=""
class="android.view.View"
…
bounds="[0,0][0,0]"
/>
</node> A few patterns worth internalising:
- The name wins. Wherever the accessible name comes from (
aria-label,alt, text content, a label), it becomes the node’stext— andaria-labelreplaces inner text on this build.content-descstayed empty everywhere in the web subtree except link wrapper nodes. - Roles are lossy. Control roles become platform classes and flags (
Button,CheckBox,EditText,SeekBar,Image,RadioButton). Roles the dumper has no attribute for are not exposed as such —headinganddialogproduce no distinguishingclassor attribute in the XML (Appium can surface aheadingattribute on API 28+ where the platform sets it; the dump itself cannot). sr-onlypresent,aria-hidden/display:noneabsent. Both are correct WCAG-conformance behaviour, not bugs. This becomes important later.- The dump is not a stable mirror. A link serialised as a clickable wrapper+child in one dump and a plain
TextViewin another. Tree construction varies run to run — one more reason to treat it as a snapshot, not a DOM.
Gotchas that look like bugs
1. The first dump is incomplete: the WebView’s accessibility tree is lazy
This is the finding behind “content doesn’t appear until I run the dump a second time” — and, very often, behind “my modal is sometimes missing from the XML”.
The WebView builds its accessibility tree lazily, on first access. uiautomator dump opens a fresh connection each time, so:
- After a cold start, dump #1 (even 10 s after the page settled) returns 8 nodes: the app chrome plus a bare
<node NAF="true" … class="android.webkit.WebView" …>with no children. - Dump #2, seconds later, returns 90 nodes: the whole gallery.
Verified A/B on the same settle time:
no flag, first dump: 8 nodes, 0 gallery text ← bare WebViewno flag, second dump: 90 nodes, gallery text ← populatedTwo control experiments rule out the obvious explanations:
- It’s not a general
uiautomatorwarm-up. A native app (Settings) dumps 92 nodes on the first try. - A bound accessibility service does fix it. On a re-visit of this device with TalkBack enabled and bound (verified via
dumpsys accessibility— don’t trust the settings toggle alone: a failedsettings putonce left our control reading identical), the first cold dump was complete, whether TalkBack was bound before or enabled after page load — a live client keeps the renderer’s accessibility on from content load. The original study capture of “TalkBack on, first dump still 8” was evidently taken while the service was still binding (dumpsysshowsBound services:{}for the empty state; the setting alone can lie for a few seconds). What has no effect is weak-a11y modes that don’t keep a client connected. - Without a bound service, the lazy tree is per-WebView-and-per-connection: every fresh connection to a fresh WebView costs one incomplete first dump.
The reliable fix is a WebView command-line flag. WebView isn’t launched from a
terminal, so it can’t receive argv like a normal process. Instead, Chromium checks
a fixed debug path at every startup: if /data/local/tmp/webview-command-line
exists, its contents are treated as the process’s command line. The file doesn’t
exist by default — you create it with a single echo and delete it when done.
/data/local/tmp/ is the one spot under the protected /data partition that
adb shell can write to without root, which is why the debug hook lives there.
Three parsing details: the first token is ignored (that’s the _ placeholder);
the file is only read at process start, so the app must be force-stopped for the
flag to apply; and it’s read by the app’s WebView process, not shell, so the file
must be world-readable — a default echo gives 0644; if your shell’s umask is
stricter, chmod 0664 it or the flag is silently ignored.
adb shell "echo '_ --force-renderer-accessibility' > /data/local/tmp/webview-command-line"adb shell chmod 0664 /data/local/tmp/webview-command-line # read by the app, not shelladb shell am force-stop com.example.androidapp # restart the app so the flag is readWith the flag in place, the first cold dump returns 90 nodes. Don’t confuse the
file with /data/local/tmp/chrome-command-line — that sibling path is read only by
the Chrome browser app, not by embedded WebViews; using the wrong name silently
does nothing. And the flag applies device-wide: leave the file in place and every
WebView app keeps the behaviour. Remove it (and force-stop again) when you’re done —
the cleanup command is in Reproducing.
Caveat, verified while testing: on a stock user build the file is ignored.
Chromium gates command-line flags to userdebug/eng images, and the OnePlus 6 used
here is userdebug — on a Pixel 7 Pro (ro.build.type=user) the same file did
nothing and the first dump stayed empty. On a user build your only option is to warm
the tree (dump twice); the flag is a userdebug/eng tool.
This also explains the modal mystery. The component is there; its tree just isn’t materialised yet. On a warm tree, JS modals are rock-solid:
- 15 consecutive dumps of a screen with the modal open: dump #1 empty (cold), dumps #2–#15 all 95 nodes with the modal present.
- 10 separate dump invocations once warm: 10/10 present.
- Open → close → reopen → each reflected on the very next dump (re-verified 12/12 in a later session, including a +0.7 s turn-around, with and without an accessibility service bound).
- The tree survives 60 s of idle and app background/foreground cycles.
So if you’re seeing “sometimes found, sometimes not”, the usual culprit is an automation loop that opens a fresh dump per action — each fresh WebView (or freshly restarted app) costs you one empty first dump. Warm the tree once, or set the flag, and it stops.
One more trap in the same neighbourhood: if the screen locks between steps, uiautomator dump either returns the keyguard hierarchy or fails with ERROR: null root node returned by UiTestAutomationBridge. Keep the screen awake and unlocked during automation.
2. Bounds are device pixels — and they go stale after scrolling
Dump bounds are raw device pixels. CSS pixels = device px ÷ (density ÷ 160). On the test device the user-set density override is 314, so the factor is 1.9625:
window.innerWidth(asked over the debugging socket): 551 CSS px.- The WebView container’s dumped width: 1080 device px.
551 × 1.9625 ≈ 1081✓.
Beware density overrides: they silently change the CSS↔px factor, which breaks hard-coded tap coordinates.
Scrolling is weirder. After a swipe scroll (verified: window.scrollY = 1768 of 3439), the whole web subtree’s bounds collapsed to [0,0][0,0] — top content and newly-visible bottom content alike — even though every node stayed in the tree. Scrolling back to the top restored real bounds. Treat node bounds as a snapshot that needs a settle/refresh after scroll, not as live geometry.
3. id → resource-id: works on current builds, regressed once in 2024
Every engine we tested surfaces the HTML id attribute as the node’s resource-id:
- WebView 115 (Google Android System WebView, the OnePlus 6)
- Chrome 151 (an embedded WebView, no accessibility service)
- Chrome 153 (the browser, on the second test device)
- Vanadium WebView 152 (same device)
Chromium confirmed this was a regression and fixed it in M127 (July 2024): the CL “Always serialize html id for ax_objects” (7caa57bd5) re-added the export explicitly “to unblock third party testing frameworks on Android” — closing Chromium issue #328681381, which the Appium project had filed (appium/appium#19848). So the breakage covered roughly Chrome/WebView 120–126; 127+ exports it again, and every 2026 build we tested does too.
One residual nuance, separate from that regression: with an accessibility service already running, a fresh dump can still miss resource-id. Chromium logged this separately as issue #357573053 (suspected cause: HTML-id serialisation gated by the tree’s AX mode; still open, P3, as of 2026). The export is conditioned on how the tree is built, not only on the WebView version.
For automation that must survive WebView updates, resource-id is still not the safest handle — it has broken once. The durable handle is role + accessible name — the text/content-desc a screen reader announces:
- Developer: give each control a stable, unique accessible name — real visible text for text controls,
aria-label/altfor icon-only controls, and no two controls sharing a name. A tester can then locate abuttonnamed Save. - Tester: query
role+ name instead of anid. - If a DOM handle (
id/data-testid) is genuinely required, that is the DOM path, not the a11y tree — it exists only in theWEBVIEW/chromedriver context (or a release-signed QA flavour with the debugging socket). Don’t fake handles into the a11y tree (e.g. anaria-labelset to an internal id, or sr-only id text).
| Automation engine | Handle to use | Handle to avoid |
|---|---|---|
A11y tree (uiautomator dump / NATIVE_APP) | role + accessible name (text / content-desc) | resource-id from HTML id (regressed once, 2024); data-testid |
DOM (WEBVIEW / chromedriver) | id, data-testid, real selectors | — |
One related proposal worth resisting: “let’s put an id on every element (or build tooling that auto-annotates them) so the testers always have a handle.” Blanket annotation is a maintenance tax — across many modules and teams the ids drift, go unused, and couple tests to DOM structure instead of behaviour. The industry consensus is the opposite: query by role, label and text first, prefer data-testid only where semantics genuinely can’t be used (it sits last in the standard query priority), and locate by role in browser automation. A test handle is the fallback, not the default.
4. content-desc and text are not what you might expect
On this WebView build, aria-label maps to text, not content-desc, and it replaces inner text. Links are the exception, sometimes emitting a wrapper whose content-desc holds the link text. Neither is consistent across versions. If you’re writing automation against web content, query by the name you can see (the accessible name) rather than assuming which attribute it landed in.
The two myths
Myth 1: “the app must be debuggable to read the dump”
False. The a11y path does not need any debug flag. Proof from the study: we built the launcher as a release APK — android:debuggable absent, no DEBUGGABLE in dumpsys package flags — installed it, and uiautomator dump returned the full page tree exactly as the debug build did.
Where does the myth come from? WebView remote debugging (the Chrome DevTools / DOM path) does care about debuggability — and people conflate the two. The dump you get from uiautomator is the accessibility tree, which has nothing to do with debugging sockets. (There is also a partial conflation with the lazy tree: when the first dump came back as a bare WebView, the assumption “it must need special flags” was natural.)
Myth 2: “we can surface data-testid into the XML”
False, and structurally impossible. data-testid is a non-semantic attribute — Blink’s accessibility tree only carries semantic properties (role, name, state), so data-testid never enters it. In the study, the element with data-testid="my-test-hook" produced a node whose resource-id was empty and whose XML contained no trace of the attribute.
Where data-testid does work is the DOM path (see below) — but only when the WebView actually exposes its debugging socket, which is exactly the debuggable/socket story everyone is really confused about.
Two ways automation reads your app
Automation frameworks on Android have two engines with different contracts:
NATIVE_APP (accessibility tree) | WEBVIEW (DOM over CDP, the Chrome DevTools Protocol) | |
|---|---|---|
| Source | The same AccessibilityNodeInfo tree uiautomator dump reads | The real HTML DOM, served over the WebView’s debugging socket via CDP (chromedriver) |
| Works on | Any app — debug or release, no special build | Only when the WebView exposes its debugging socket |
| What you get | text, roles→classes, flags, bounds — but no DOM, no data-testid, no document structure | Full DOM: real selectors, data-testid, computed styles |
| Requires | Nothing beyond the app itself | WebView.setWebContentsDebuggingEnabled(true) — auto-on for debuggable apps since WebView 113 — or a debug-enabled build |
For a WebView-heavy app, the two engines fit different jobs. The DOM/CDP path is the natural tool for functional coverage of the web layer — real selectors and data-testid — which is why hybrid-mode automation drives WebViews through chromedriver. Its catch is the socket: it exists only on a debug-enabled build or a userdebug device, so it can’t validate the production artifact. The a11y tree is the opposite: lossy, but present on every build, release included. The best-practice split is to use both — DOM/CDP for detailed functional tests on a debug-enabled build or release-signed QA flavour, and the a11y tree for the production sign-off and accessibility checks.
The production-build rule, then, is: the a11y tree works on a production APK; the real DOM only exists if the WebView debugging socket is on. “The production build’s HTML isn’t accessible” is a build/tooling property (the socket), not something any HTML change can fix. If you genuinely need DOM automation against a production-representative build, the path is a release-signed QA flavour with web debugging enabled — still signed, still UAT-eligible, but deliberately not the exact production binary.
One nuance the study surfaced: the socket gate is per-device, not per-app-config. This test device is a userdebug build (ro.build.type=userdebug, ro.debuggable=1) — and on userdebug/eng images and emulators, WebView debugging is enabled for every app by default, so our non-debuggable release APK exposed a working DevTools socket anyway. On a stock user build it would not. That asymmetry is why you must verify DOM-automation assumptions on a device that actually mirrors production.
The a11y path, by contrast, was identical on both builds: no debug flag, no socket, no special config.
Test what users experience — without warping the markup for a scraper
The a11y tree is the structure users experience: TalkBack reads it, screen readers navigate it, accessibility services act on it. So the established practice is to query it the way a user would — by role, name and accessible text. That is Testing Library’s principle: “the more your tests resemble the way your software is used, the more confidence they can give you.” Role-based locators and accessibility-tree snapshot assertions are the default in mainstream browser automation, and automated accessibility scans are a standard CI gate, complemented by manual and real-user testing.
Caveat: many resilient end-to-end suites do use data-testid — it is a legitimate escape hatch in DOM contexts. It just never exists in the accessibility tree, which is exactly the seam this post maps.
The same view exposes a guardrail. sr-only text is in the tree; aria-hidden content is not. Both are correct — they are the WCAG conformance mechanism. An automation team that “fixes” a missing locator by copying marketing text out of aria-hidden, or by turning a decorative image into a real one, is warping production semantics to satisfy a scraper — a classic case of test-induced design damage (a phrase coined by DHH in 2014; ThoughtWorks published a balanced counterpoint the same year). The production markup should serve users first; automation locators should be built on top of the same semantics, not the other way around.
| Do | Don’t |
|---|---|
Keep sr-only text — it’s the WCAG mechanism for making content available to assistive tech, and (as this post shows) to the a11y tree | Copy text out of aria-hidden elements just so a scraper can find it |
Mark genuinely decorative images with empty alt or aria-hidden | Promote a decorative image to a meaningful one only to satisfy a locator |
| Fix a missing locator by fixing the semantics (a real label, a real accessible name) | Change the DOM structure only to make a scraper’s selector match |
The sr-only / aria-hidden split: a worked example
A common screen-reader requirement is one clean announcement — “Contact James Roger has 23 unread emails” — built with the classic split:
<div> <span class="sr-only">Contact James Roger has 23 unread emails</span> <div aria-hidden="true"> <span>Contact:</span><span>James Roger</span> <span>23</span><span>emails</span> </div></div>The announcement is perfect. The problem: aria-hidden="true" removes the entire breakdown from the accessibility tree, so neither a screen reader nor a11y-tree automation can reach the individual values. Verified in the WebView dump: the tree contains the sr-only sentence and nothing else from the breakdown — no Contact:, no 23.
Is this a bad pattern? First, notice that “Contact James Roger has 23 unread emails” is not the same utterance as the four pieces: the sentence carries the meaning — the relationship, the count — while the pieces are a fragmented visual rendering. So the sr-only sentence is not duplicating the visible content; it is the coherent statement the visual design otherwise hides. If that statement is the whole story, sr-only + aria-hidden is a legitimate pattern, and automation can still target the sentence — it is in the tree, as the dump above shows. The pattern only becomes a problem when an individual value is itself data someone needs to address — a count, a name, a link — because aria-hidden removes it for real users too, not just for automation. There is no separate “automation tree” to compromise: uiautomator dump reads the same tree TalkBack reads, so the decision is never “users vs. tests” — it is what belongs in the tree, decided for users.
Can you have both — the clean announcement and individually reachable values? We tested the candidates with TalkBack, and in this WebView the answer is no:
aria-labelon the<dl>itself — not announced at all:<dl>isn’t nameable, so TalkBack skips the label.role="group" aria-labelwrapping the<dl>— the tree carries the sentence on the group node and the values as discrete nodes, but TalkBack does not speak the group’s label. It reads only the<dl>values, with the structure:Contactterm,James Rogerdefinition,Emailsterm,23definition. The constructed line is never said.sr-onlysentence next to a visible<dl>— the sentence is spoken, but then the values are read too, so it is not one clean announcement.
The reason is structural: the accessibility tree is the single source for both TalkBack and automation. Anything in it can be announced, and TalkBack does not reliably speak container labels (groups, definition lists) during normal navigation. No pattern we tested — and, as far as we can tell structurally, no HTML pattern — announces the constructed line and keeps the values silent-but-reachable.
The practical conclusion is to keep the sr-only + aria-hidden pattern when the clean announcement is the goal — it is the one pattern that actually delivers it, and automation can still target the sentence, because the sentence is in the tree (the dump above shows it). If a specific value must be individually reachable, that conflicts with the clean announcement; decide which matters and accept the other is lost. Do not restructure the markup to satisfy a locator — the values are hidden by design, and that is a screen-reader decision, not an automation bug.
Rule of thumb: an sr-only sentence is the reliable way to announce a constructed line, and aria-hidden removes content from the tree for everyone — users and automation alike. If a value must be individually reachable, it cannot also be hidden for a clean announcement. Pick one, and don’t let the test decide.
How UAT should run
- Distribute a signed release candidate through your platform’s testing tracks (e.g. the store console’s internal/closed tracks, or the equivalent for your distribution channel), not a debug build.
- Automate against that artifact via the native a11y tree — it works on release, no debug flag, no socket.
- Build a release-signed QA flavour only if you have a genuine DOM-automation need (and accept it is not byte-identical to production).
- Make accessibility an explicit UAT acceptance criterion: automated scans in CI, manual passes with the platform screen reader, and real-user checks — not just “does the locator find it”.
Why this matters beyond testing. Screen-reader compatibility — the same accessibility tree this post maps — is increasingly a compliance surface, not a nice-to-have. Singapore’s Enabling Masterplan 2030 commits all high-traffic government websites to WCAG 2.1 AA (MSF, EMP2030; MDDI PQ reply); private-sector apps are not yet mandated there. Where an app is offered to EU consumers, the EU Accessibility Act already covers consumer banking services, with pre-existing service contracts to comply no later than 28 June 2030 (European Commission — EAA; deadline analysis). Coding
sr-only/aria-hiddencorrectly today is what keeps an app testable and compliant tomorrow.
Practical checklist
- Want the WebView subtree reliably? Set
_ --force-renderer-accessibilityin the WebView command-line file (/data/local/tmp/webview-command-line, Gotcha 1) and restart the app; remove the file when done — the flag is device-wide. Otherwise expect the first dump of each fresh WebView to be empty — re-dump or warm the tree once. - Target by accessible name (
text/content-desc) and role-derived classes — notresource-id(it regressed once, in 2024) and neverdata-testid. - Bounds are device px. Compute the CSS↔px factor from the live density (including overrides), and treat bounds as stale after scroll.
- Keep the screen awake and unlocked between steps; use
uiautomator dump --windowswhen the thing you want lives in another window. - Verify on a production-representative device. If your automation depends on the DOM path, remember userdebug/eng devices and emulators enable the socket for all apps; stock user builds do not.
Reproducing the results
Environment: any device with a WebView launcher app and adb (this study used the author’s simple WebView launcher, which just loads a URL you pass it), or the same fixture over your LAN. All facts above were captured against a OnePlus 6 (LineageOS 20, ro.build.type=userdebug), WebView com.android.webview 115.0.5790.136, Android 13, screen 1080×2280 with a density override of 314. The id→resource-id and lazy-tree findings were re-verified on a second device — a Pixel 7 Pro (ro.build.type=user), where Vanadium WebView 152 and Chrome 153 both export resource-id (and where the --force-renderer-accessibility flag is ignored).
Serve the fixture (any static server works):
python3 -m http.server 8000 --bind 0.0.0.0 # fixture/gallery.htmlLoad it in the launcher and dump:
adb shell am start -n com.example.androidapp/.MainActivity --es url "http://192.168.0.29:8000/gallery.html"adb exec-out uiautomator dump /dev/tty # first dump: bare WebViewadb exec-out uiautomator dump /dev/tty # second dump: full pageForce the tree eagerly (what the file is and why it works is in Gotcha 1):
adb shell "echo '_ --force-renderer-accessibility' > /data/local/tmp/webview-command-line"adb shell chmod 0664 /data/local/tmp/webview-command-lineadb shell am force-stop com.example.androidapp# relaunch, then a single dump now returns the full page
# cleanup: restore normal behaviour when you're doneadb shell rm /data/local/tmp/webview-command-lineadb shell am force-stop com.example.androidappProve the data-testid and id facts, and check the screen-reader and scroll behaviour, by dumping the corresponding fixture cases.
The full fixture (gallery.html) contains 34 cases, including the sr-only/aria-hidden comparison at the end (cases 32–34, the ones the worked example above uses), the JS modal (?auto=modal), delayed content (?auto=delay), id/data-testid, roles, form controls, and a long scroll region. The fixture and the raw dumps are published with this post: gallery.html, the rendered tree, and the raw warm dump.
Appendix: the three accessibility states
| State | First dump after cold start |
|---|---|
Baseline (accessibility_enabled=0, no flag, no client) | 8 nodes, bare WebView |
Screen reader on (TalkBack bound — verified via dumpsys accessibility) | full page — a live client keeps the renderer’s accessibility on |
--force-renderer-accessibility flag | 90 nodes, full page |
The flag row only applies on a userdebug/eng build — on a stock user build the file is ignored (see Gotcha 1).
Sources
- AOSP
AccessibilityNodeInfoDumper.java— the serializer behinduiautomator dump(attribute set, NAF, window handling). - Chrome remote debugging WebViews —
setWebContentsDebuggingEnabled; Android docs note the manifest debuggable flag does not gate the socket. - Chromium net-debugging.md — “WebView debugging is automatically enabled by default if you use either a userdebug or eng Android image … also … a debug app build.”
- Chromium issue #328681381 — WebView builds in the ~120–126 range stopped exporting HTML-
id→resource-id, breaking Appium locators (first widely reported against 122); marked Fixed (see next). - Chromium commit 7caa57bd5 — “Always serialize html id for ax_objects” (M127): the fix that restored the export, explicitly to unblock third-party test frameworks.
- Chromium issue #357573053 — residual: with an accessibility service running, a fresh dump can still miss
resource-id(suspected AX-mode gating; open, P3). - appium/appium#19848 — Chrome 120+ no longer exposes
resource-idfor WebView content inNATIVE_APPcontext; closed as an upstream Chromium issue. - Testing Library guiding principles; query priority (
data-testidas last resort); Playwright locators / ARIA snapshots / accessibility testing. - DHH, “Test-induced design damage” (2014); ThoughtWorks, “…or fallacy?” (2014).
- WebView command-line flags (
/data/local/tmp/webview-command-line; Chromium docs note the mechanism targetsuserdebug/engbuilds — matching the test device here) — Chromium commandline-flags.md.