Repository navigation
refactor(ios-runner): acquire first, present after — one geometry normalization pass instead of per-walker threading #2661
Description
Why
In the XCTest runner, acquisition and presentation are interleaved in the recursive tier, so every geometric fact has to be threaded through every walker by hand.
Evidence from PR #2653 (fixes #2612), which added one fact, "which coordinate space is this node in":
- It had to touch three walkers (
recursiveTreeSnapshotAcquisition,rawTreeSnapshotAcquisition, the private-AXappendPrivateAXNode) plus the collapsed-tab side path (collapsedTabFallbackNodes/collapsedTabCandidateNode) inapple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swiftandRunnerTests+PrivateAXPresentation.swift, addinggeometrySpace/parentIsWindowparameters to each frame of each walk. - The query-sweep tier (
querySweepSnapshotAcquisition) got nothing, because it has no window ancestry inside its walk, so that tier still publishes unnormalized geometry (ADR 0004, "What stays unnormalized"). - The collapsed-tab expansion needed a special guard (
guard geometrySpace == .appOrientation else { return [] }) because it reads live element frames that are already in the app's space.
The reason a post-acquisition pass could not do this in one place is at RunnerTests+Snapshot.swift around line 244: the recursive walk calls SnapshotPresentation.regularTraversalTransition(for: node, …) mid-walk, consulting the visibility fold to decide the presented depth and whether to descend. The fold reads node geometry, so geometry must be final before the fold sees it, so it must be computed inside the walk. The private-AX tier does not have this coupling: it acquires the whole raw tree and presents afterwards through SnapshotPresentation.present(acquisition, options:) (RunnerTests+SnapshotCapturePlan.swift, captureWithBackend).
Wider counts on main (same files): 9 production RawAXNode( construction sites across 5 files; 5 call sites reading RunnerSynthesizedGesture.interfaceOrientation(forApplication:); 13 sites reading the app viewport (app.frame, safeSnapshotViewport, app.windows.firstMatch.frame). The 2026-08-31 presenter-convergence review already flagged "presented-depth frontier coupling (acquisition consults fold decisions mid-traversal) must be redesigned before any runner demotion".
Task
Make the recursive tier acquire first and present after, like the private-AX tier already does, so that geometry normalization becomes one pass over the flat [RawAXNode] array.
- Establish what the mid-walk fold buys. The recursive tier walks
XCUIElementSnapshot.children, which XCTest materialized in one bulk snapshot; the frontier does not save AX round trips, it saves walk time and node construction on subtrees the fold would clip. Measure on the deep-tree benchmark (scripts/bench harness used for perf(ios): align AX recovery contracts and evaluate generation-scoped depth hints #2424 / the deep-tree work; seedocs/for the recipe) and on the test app: raw node count walked with and without the frontier, and wall time. Report the numbers before changing anything. If the frontier is load-bearing for a known hostile screen (Bluesky feed, Runner resilience under agent-abuse command patterns #1105/iOS physical device: latest npm 0.19.0 still hits XCTest main-thread timeouts in RN overlay/list states #1156), keep a bound that does not consult presentation: a raw depth / node cap is acceptable, a fold decision is not. - Acquire raw, then normalize, then present. Walkers produce
RawAXNodewith reported frames only. AddSnapshotGeometrySpace.normalized(nodes:viewport:interfaceOrientation:) -> [RawAXNode]next to the existing rule inapple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift: walk the array once, derive each node's space fromtype,parentIndexandrectwith the existingspace(reportedBySurfaceHost:…)rule, rewriterectthroughorientedFrame(of:), and recomputehittablewithSnapshotGeometry.isGeometricallyActionable. Run it once incaptureWithBackendbetween acquisition andSnapshotPresentation.present. Delete thegeometrySpace/parentIsWindowthreading from every walker and the collapsed-tab guard (collapsed tab containers sit under the app's own window, so a post-pass keyed on ancestry never touches them). - Bring the query-sweep tier under the same pass. Its flat nodes hang off the application root; with no window ancestry they declare no space and the pass leaves them alone, which is the current behaviour, but now for a structural reason rather than an omission. Say so in ADR 0004.
- Keep the disclosure.
unplacedSurfaceHostCount(added after fix(ios): publish captured keyboard geometry in the app's orientation space #2653) already runs post-acquisition; it should now run after normalization and be zero whenever the orientation was readable.
Acceptance criteria
-
SnapshotGeometrySpaceis referenced from exactly one production site inapple/runner/**(the normalization call incaptureWithBackend); no walker takes ageometrySpaceorparentIsWindowparameter. - Every existing runner unit test that asserts rotated output still passes unchanged:
testPrivateAXAcquisitionPublishesATurnedSurfaceHostInAppOrientationSpace, theRunnerTests+Snapshot.swifttests added in fix(ios): publish captured keyboard geometry in the app's orientation space #2653,testStampedPayloadDisclosesUnplacedSurfaceHosts. The package suite (swift test --package-path apple/snapshot-presentation) gains a test fornormalized(…)that replays therotationCasesofcontracts/fixtures/window-coordinate-space.jsonthrough a two-window tree. -
contracts/fixtures/snapshot-presentation-conformance.json,ios-snapshot-backend-conformance.jsonandios-ax-recovery-conformance.jsonare unchanged and green in both languages. - Live:
snapshot -i --jsonon the test app in portrait is byte-identical tomainon the runner path (confirm the producer isapple-runner, not the bridge: the bridge fingerprint is the "does not provide hittability evidence" warning). In landscape with the keyboard up, the band and key rects match the fix(ios): publish captured keyboard geometry in the app's orientation space #2653 numbers ((75,198,724,202)band, keyqat(77,203,72,45)). - Benchmark: capture p50 and p95 on the deep-tree harness and on the Bluesky feed not regressed by more than 5 % against
main, with the numbers in the PR. If the frontier had to be replaced by a raw cap, the cap and the screens it was calibrated on are named in ADR 0004. -
pnpm check:affected --rungreen; runner units green underAGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1;pnpm test:ios-snapshot-differentialgreen.
Non-goals
Runner demotion or moving presentation to the host: that is a separate, measurement-gated decision (ADR 0004 host-side ownership boundary; two presenters was the measured optimum on 2026-08-31). Touching the Android or bridge producers.
Related: #2612, #2653, #1797 (acquire/present split), #2188 / #2199 (iOS snapshot convergence), ADR 0004.
Activity
refactor(ios): keep only the coordinate-space package move; drop scop…
thymikee commentedon Sep 18, 2026
Note for step 4 ("keep the disclosure"): I tried an
unresolvedCoordinateSpaceWindowsdisclosure in #2664 and cut it. The only path where a runner tree can still hold turned geometry after the walkers is the private-AX tier with a penalized XCTest channel (privateAXSnapshotGeometryreturnsRunnerInterfaceOrientation.unknownthere and anchors on the bridge's root frame). Two things are unmeasured about that path: the root frame is.infinitewhen the bridge reports an empty root, which makes any geometry check a no-op by construction, and when it is finite nobody has measured which space it reports in. So a disclosure there cannot be shown to fire. If this PR's one-pass normalization wants a disclosure, it should be a one-liner after the pass (turned host still present ⇒ warn), and it needs one live capture on that exact path first — Bluesky feed (#1105/#1156 penalized-channel screens) in landscape with the keyboard up is the candidate. Until that capture exists, do not add it.refactor(ios): move the coordinate-space rule into the presentation p…
refactor(ios): normalize snapshot geometry in one pass after acquisit…
refactor(ios): normalize snapshot geometry in one pass after acquisit…
refactor(ios): normalize snapshot geometry in one pass after acquisit…
refactor(ios): normalize snapshot geometry in one pass after acquisit…
refactor(ios): normalize snapshot geometry in one pass after acquisit…
thymikee commentedon Sep 21, 2026
Closed against
mainat45e4c594a1. PR #2669 (f4930d6346) on prerequisite #2664 (a556e114cd), both ancestors ofHEAD.Item 1 — what the mid-walk fold bought. The frontier was dropped rather than re-bound. A regular
--depthis now a presentation cut only: the walk's bounds are raw traversal depth plus a runaway node cap, andSnapshotPresentationapplies the visibility fold and the presented-depth cut on the normalized array. ADR 0004 records the query-sweep structural reason and the frontier-drop measurements, which is what this item asked for.Item 2 — acquire, normalize, present. One production site:
SnapshotGeometrySpace.normalized(nodes:viewport:interfaceOrientation:)atRunnerTests+SnapshotCapturePlan.swift:487, insidecaptureWithBackendbetween acquisition andSnapshotPresentation.present.grepforgeometrySpaceandparentIsWindowacrossapple/runner/**returns 0 hits, so no walker carries a coordinate space and the collapsed-tab orientation guard is gone. The rule itself lives atapple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotCoordinateSpace.swift:160.Item 3 — query sweep. Covered structurally: with no window ancestry the sweep's nodes declare no space and the pass leaves them alone, now for a stated reason in ADR 0004 rather than an omission.
Item 4 — the disclosure. Not applicable as written.
unplacedSurfaceHostCountwas cut in #2664 and the thread retracted re-adding it without a live penalized-channel capture. 0 references onmain, andtestStampedPayloadDisclosesUnplacedSurfaceHostswent with it.Coverage.
CoordinateSpaceTests.swift:415-456replays therotationCasesofcontracts/fixtures/window-coordinate-space.jsonthrough a two-window tree and pins the #2653 landscape numbers verbatim (band(75,198,724,202), keyqat(77,203,72,45)).#2669 recorded at head
47b3aa12:pnpm check:affected --rungreen (3011 unit tests),swift test --package-path apple/snapshot-presentation14/14, runner units withAGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1. The two remaining unchecked boxes — portrait byte-identity on a live simulator and the deep-tree/Bluesky benchmark inside 5 % — are PR-time evidence carried in that body; I am not re-running them here, so treat them as attested at47b3aa12rather than re-measured at this tip.