feat(describe-ui): Add --include-web-content for WKWebView page content - #66
Conversation
WKWebView and SFSafariViewController render their pages in a separate WebContent process, so the in-process accessibility walk cannot see any of the page's elements. This adds an opt-in describe-ui flag that discovers that content by hit-testing a grid of screen points across the process boundary, and plumbs the options through AccessibilityFetcher to FBControlCore's remote-content serializer. Discovered elements report "is_remote": "point_grid" so callers can tell a hit-tested element from a natively-walked one; the default JSON schema is unchanged when the flag is off. Two deliberate choices are documented inline because both look like mistakes until explained: - collectFrameCoverage stays off: real screens are full of full-screen containers that saturate the coverage grid during the walk, which suppresses every probe and discovers nothing, silently. - The grid step defaults to 25pt rather than the library's 50pt: a 50pt grid leaves ~17pt bands between probe rows and silently missed a standard 20pt-tall link while returning a complete-looking tree. Includes a WKWebView playground fixture screen (static HTML, no network) and five tests: three command-surface, two E2E against the fixture (gated behind AXE_E2E, like the existing simulator tests).
b8e9fbe to
b0857c2
Compare
|
@underscoretang This is an interesting approach, I would like to understand more from a performance point of view and also how this handles scrolling content and content that is currently offscreen. |
Benchmarking the flag turned up two behaviors the help text did not
describe:
- Hit-testing samples screen points, so a call only ever discovers
content in the current viewport. Scrolling and re-running reaches the
rest, but a single call never sees below the fold.
- With the flag on, natively walked elements also carry an is_remote
marker ("recursive"); only "point_grid" is grid-discovered content.
Also notes that whole-screen probing can pick up other out-of-process
UI, such as the status bar, and points at the unused region option as
the way to bound that.
|
[AI: ] 🤖 Both good questions, and the second one turned out to be the more interesting of the two. I went and measured rather than guess. Everything below is a release build against an iPhone 17 Pro simulator (iOS 26.5, 402x874 pt), 12 runs per configuration after warm-ups, median wall clock for the whole PerformanceThe cost model is simpler than I expected. Each probe point costs about 1.44 ms, and the number of probes is just the screen area divided by the grid step squared, so: On this screen that's 162 probes at 50 pt, 595 at 25 pt and 2310 at 12.5 pt, and the measured per-probe cost barely moves across them (1.41 / 1.44 / 1.53 ms). Halving the step quadruples the bill. In absolute terms at the 25 pt default:
With the flag off nothing changes, since it's the same path and the same output. While I was in here I corrected my own PR description: it claimed "+660 ms", which was one measurement generalised further than it deserved. The honest number is a range, and it's in the description now. If that's too expensive to be comfortable, On the default itself, the numbers back 25 pt but not strongly on either side. Counting distinct page elements found on a dense page: 50 pt finds 17, 25 pt finds 19, 12.5 pt finds 19. So 50 pt genuinely drops two real links while still returning a tree that looks complete, which is the failure mode I wanted to avoid, and 12.5 pt costs four times as much for nothing at all. 25 pt is where the yield flattens out. Scrolling and offscreen contentOnly what's on screen is discovered, and scrolling needs no special handling. I put 40 evenly spaced marks on a page, about six to a viewport, and alternated So a single call sees exactly the current viewport and never reaches below the fold, no matter how fine the grid. Scroll and call again and you get the new viewport, with frames in real screen coordinates, so anything discovered stays tappable by coordinate. For a caller this behaves like the native tree already does with virtualised lists: you see what's rendered. Worth flagging that the two gaps above (14 and 21) are inertial scroll overshoot between passes rather than the grid missing anything. I checked by holding one scroll position and sweeping the step: 50, 25, 12.5 and 6.25 pt all return the identical six marks. Two things the measurements turned upNeither is fatal, but both were undocumented and both are now in the follow-up commit. With the flag on, natively walked elements also come back carrying The grid covers the whole screen, so on a screen with no web view at all it still returns three things: a status bar group, the clock, and "Dynamic Island, Empty". They're genuinely out-of-process elements, so the mechanism is behaving correctly, but "web content" is a narrower promise than what the flag delivers. That second one has an obvious fix I deliberately haven't written: The benchmark harness is three short scripts and a couple of generated pages, no network. Glad to include them in the PR if you'd want the numbers reproducible in-tree, or to leave them out to keep the diff tight. |
For Humans
Fixes #65 — opened as a draft to propose the approach.
describe-uican't see inside a WKWebView or SFSafariViewController, because the page renders in a separate WebContent process the in-process accessibility walk never reaches. With the new opt-in--include-web-contentflag, it hit-tests a grid of screen points across that process boundary and returns the page's headings, links, buttons, and fields as ordinary elements with real tappable frames 🔍. It reuses remote-content options FBControlCore's serializer already supports at the pinned idb rev — no dependency changes, and the default output stays byte-identical when the flag is off.Visuals
Before — default walk on the playground's WKWebView fixture screen (page content invisible):
After — same screen with the flag; every page element is discovered, typed, framed, and tagged:
(Real output from a booted iPhone 17 Pro simulator, iOS 26.5; trimmed to the discovered elements.)
Conversation Highlights
That call shaped the final surface: the option help strings are one-liners matching the file's idiom, and the measurement evidence behind the two non-obvious defaults lives in this description instead of inline comments.
Stack
Not stacked.
Created with: claude-fable-5
For Agents
Reviewer question
Should
describe-uigain an opt-in point-grid discovery path for out-of-process WebKit content, shaped as one enabling flag plus two tuning options (--web-content-grid-step,--web-content-max-points)?Diff size: 73 changed production lines (
Sources/); 251 total (+245/−6) including the playground fixture and tests.Load-bearing code
DescribeUI.swift:22-64— the three CLI declarations and scoped validation (guards apply only when the flag is on; tuning options document their inertness otherwise).AccessibilityFetcher.swift:262-272— the serializer call:is_remoteis unioned into the request keys only in remote mode, which is what keeps the default JSON schema unchanged; thecollectFrameCoveragecomment marks the decision that looks like a mistake.DescribeUITests.swift:167-224— both E2E legs share a readiness poll (WKWebView paints asynchronously) and assert label, type,is_remote, and a positive frame.WebContentTestView.swift— deterministic fixture: static HTML, no network, scrolling disabled, native sibling label so tests can tell "screen is up" from "web content discovered".Design decisions (the two that look like mistakes until explained)
collectFrameCoveragestays off. Turning it on looks like a free optimization — skip hit-testing points the native walk already covered. In practice the serializer fills the coverage grid from every non-Application frame, and real screens are full of full-screen containers (Safari alone contributes 28), so the grid saturates to 100% during the walk and the skip check suppresses every probe: zero web nodes discovered, silently, exit 0. With coverage off, every grid point is probed; duplicate hits are collapsed by PID and frame de-duplication, and--web-content-max-pointsbounds the cost.Other behavior worth knowing
--include-web-content+--pointis rejected with a clear message: a point lookup already resolves web content across the process boundary.children— the process boundary means there is no ancestor chain to nest them under the hosting web view. Consumers matching by label/type/frame are unaffected.added_ms ≈ 1.44 × ceil(width/step) × ceil(height/step). On a 402x874 pt screen the 25 pt default is 595 probes, measured at +530 ms to +860 ms per call depending on the screen (2.3x-4.8x baseline). Halving the step quadruples that.--web-content-max-pointscaps the worst case: on a screen with no web content it cuts the overhead from +530 ms to +52 ms with no loss in what's found.is_remote: "recursive"; onlypoint_gridmarks grid-discovered content. Whole-screen probing can also return other out-of-process UI, such as the status bar.FBAccessibilityRemoteContentOptions.regionis the unused knob that would bound both.Tests/Goldens/**is capture-time release evidence whoseprovenance.jsonpins astable_contract_sha256; hand-editing the help stdout would break that contract, andscripts/regenerate-goldens.shrecaptures the new help output at the next release regeneration.Mechanical / safe to skim
ContentViewscreen routing (+4), the launch-helper wait key (+2, keyed on a native element since web content is invisible to a plaindescribe-ui), and the 3-lineis_remoteaddition to the test JSON decoder.Verification
swift buildclean; full non-E2E suite 221/221.AXE_E2E=1like the existing simulator tests.Risk & rollback
Flag-off behavior is byte-identical (asserted end-to-end by
describeUIOmitsWebContentByDefault), no dependency changes, single commit — revert is one cleangit revert.