refactor(android): raw is the acquired tree; one presentation for dialog recovery; residues declared (#1832 C3)

Merged
TH
3mo ago
+1184-917

Summary

Second slice of #1832: Android behavior changes plus one behavior-neutral shared extraction. 28 files — the Android snapshot family, plus buildSnapshotState moved verbatim out of handlers/snapshot-capture.ts into src/daemon/snapshot-state.ts with its importers repointed (generic-settle, iOS presentation, interaction fixtures) and its 409 lines of tests moved to mirror it (28th file = the occlusion regression added in review). The moved function is byte-identical to main's; no iOS or shared behavior changes.

Covers step 4 (pruners into the regular projection), step 8 (residues), and the system-dialog half of step 3. The other half of step 3 — the Android RawAXNode fixture schema — is deliberately not here: its only consumer is the cross-runtime conformance suite in step 9, which needs #1797's Swift presentation to exist as a callable target, so landing the format now would pin a fixture shape nothing reads.

C3 — --raw is the acquired tree. The three pruners (nodes Android marks invisible, stale application windows, covered same-window surfaces) ran inside parseUiHierarchyTree, mutating the tree before either projection existed, so --raw was not the acquired tree and pruned content was unrecoverable. They now run as a non-mutating classification (collectAndroidHiddenNodes) that the regular projection consults; --raw presents everything the helper serialized (normalization only). interactive ⊆ regular ⊆ raw holds by construction.

Mutation had two silent consequences the move exposed, both fixed here:

  • Hidden-content hints and the scope root are per-projection. The vertical-vs-horizontal overflow estimate and "is this a presentation leaf" both counted node.children, which the mutating pruners had already emptied. They now count the children that projection shows — the semantics the old order implied but could not state.
  • The tree is shared by raw, by the regular projection, and by hint derivation (which builds a second projection from the same tree), so whichever ran first decided what the others saw.

Behavior evidence. A property differential ran main's implementation and this one over 3,000 random accessibility trees × 4 projections (regular, -i, --depth 2, --scope), comparing full node signatures including depth, parent edge, hittable and both hidden-content hints: 12,000/12,000 identical. Raw grew on 2,514 of 3,000 trees and never shrank. The differential found two real divergences during development (the two children semantics above); both are fixed and pinned by unit tests. The harness was scaffolding against a copy of main and is not part of the diff.

One presentation for dialog recovery, and it acts on the result (the #1784 pattern, daemon-side): android-system-dialog.ts hand-rolled attachRefs(pruneGroupNodes(...)), skipping normalization and occlusion annotation, so blocking-dialog detection could disagree with snapshot about which button is on top. It now calls buildSnapshotState and consumes that presentation's occlusion result: both containsBlockingDialog and findCloseAppButton filter through isSnapshotNodeInteractionBlocked, so a stale ANR surface left under the foreground one no longer triggers recovery, and a covered "Close app" is never tapped ahead of the visible one. (Routing alone left that gap — caught in review.) Two regressions pin it, both proven red against unfiltered selection; the pre-existing ref-frame test stays green. buildSnapshotState moves to src/daemon/snapshot-state.ts (it needs no session state) so the daemon-server type cycle does not grow — R9/R10 stay at their baselines.

Residues declared (step 8), each at its site plus CONTEXT.md: no checked/checkable/selected/long-clickable on the wire; the 5000-node cap applied device-side before any scope; API-level cache-reset divergence. And the residue that was actually load-bearing: the Android freshness route signature keyed on role and selected, fields the Android backend never carries — two constant columns claiming discrimination they never had. Removed.

Also: ui-hierarchy.ts splits by question — node predicates, regular-projection visibility, scope — 974 → 634 LOC, each new module mirrored by its test file.

Refs #1832 (migration step 4, step 8, and the system-dialog half of step 3).

Validation

  • pnpm check:affected --run green on the rebased head (1869 tests).
  • C3 projections, live on throwaway Pixel 7 emulators: with the test app (helper 0.20.9) --raw 142 / regular 41 visible / -i 9 on one screen, and raw 267 vs regular 65 after navigating to a pushed screen — every raw-only node is visible-to-user="false", zero-height, or system chrome, i.e. exactly what the regular projection hides. Re-verified on this head after pnpm build:android for 0.20.10 (Settings: raw 62 / regular 38 / -i 16).
  • Dialog-recovery route, live: open → record start → type on a recording session drives recoverAndroidBlockingSystemDialog for real. Request diagnostics show the helper session capture running inside that request and the command proceeding — the changed call site captures through the shared presentation, finds no blocking dialog, and does not false-positive. A second run with a deliberately stale helper artifact shows the other branch (android_blocking_dialog_inspection_failed → fail-open, type still succeeds).
  • ANR recovery, live, on the positive route. A purpose-made fixture APK (one Activity that sleeps 120s on tap, built with the repo's own aapt2/d8/apksigner pipeline) staged a genuine ANR on a throwaway Pixel 7 / API 36 emulator: dumpsys window shows Application Not Responding: com.example.anrlab and the dialog reads "ANR Lab isn't responding" / "Close app" / "Wait" — the exact text the detector matches. With that dialog on screen, type on a recording session drove recoverAndroidBlockingSystemDialog through the changed path, and the request diagnostics record android_blocking_dialog_recovered with the tap it dispatched (x: 540, y: 1205 — the Close-app button), after which the dialog is gone and the app is foreground again. Detection → reachable-button selection → tap → dismissal all exercised against a real system dialog.
  • Why the earlier attempt failed, since it matters for future device work: app-error dialogs are suppressed on these emulator images until settings put global hide_error_dialogs 0 is set explicitly (it reads null on a fresh AVD). A forced am crash produced no dialog either, which is what identified the setting rather than the ANR staging as the blocker. With it set, both crash and ANR dialogs appear.
  • Remaining unit-only, as before: the synthetic covered-first ordering (a stale covered "Close app" preceding the visible one), which needs two simultaneous Close-app buttons and is impractical to stage on a real ANR.
  • Sessions closed, emulator killed, throwaway AVD deleted (Pixel_7_CI untouched).
  • Two gate runs showed unrelated timeouts (android-lifecycle provider scenario, daemon-entrypoint) while an emulator was booting on the same machine; both pass in isolation in 8.3s and 1.3s.
  • Rebased onto d29dc22 (16 commits, including #1876's capture-input builder and #1878's screenshot runtime migration). One conflict, in request-generic-dispatch.ts: main had removed the buildSnapshotState/captureSnapshotData imports this branch was repointing, so main's side won outright. One reconciliation the conflict did not surface: screenshot-runtime.ts arrived on main importing buildSnapshotState from its old home, and now points at src/daemon/snapshot-state.ts with the other importers — caught by typecheck, noted in the commit message. Still 28 files.
  • The earlier test-file-size-ratchet drift was main's own and is fixed there by #1860; this PR only lowers its own file's pin, which shrank by 2 lines.
  • provider-scenarios/settle-observation "press --settle" flakes under load on this machine — 1 of 3 runs passes on both this branch and plain origin/main (5s fixture timeout), so it is pre-existing, not a regression from this change.

Docs: website/docs/docs/commands.md (what Android --raw now shows, and the helper's state/cap limits), CONTEXT.md (projection vocabulary + declared residues), CHANGELOG.