Repository navigation
fix: derive iOS transition snapshots from visible presentation - #1831
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
P1 readiness blocker: |
|
Re-reviewed exact head 1741c51: still not ready. |
1741c51 to
e0b1dab
Compare
|
Addressed on rebased head |
|
Re-reviewed current exact head |
|
…face dismissal The confirmation #1831 added for a modal dismissal also fired on ordinary navigation, because it armed on any transition sharing under half its node identities with a complete viewport projection - true of most tab switches and pushes. It was also switched off by the requested window, so --settle-quiet 499 behaved differently from 500 and settleQuietMs: 150 callers (tester-army/e2e) never got the guard at all. Arm on the transition the rule was built for: the pre-action tree shows a surface presented over app content, which an interactive capture reports as the surface alone (a handful of nodes, or a blocking prompt's own Alert root). Decided once, from the first capture, never from the requested window; an explicit shorter window keeps the confirmation on its own floor. Closes #3470.
…on alone The arming bar was `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS`, so the confirmation that #1831 added to hold a modal dismissal for 1.5s was a function of the window the caller asked for rather than of the transition in front of the loop. Three consequences, all reported as #3470: - `--settle-quiet 499` behaved differently from 500, with no rule that explains the boundary - a caller passing `settleQuietMs: 150` (tester-army/e2e) got no guard at all, on exactly the dismissal the guard exists for - a shorter window read as "trust a coherent transitional tree sooner", which is not what `--settle-quiet` asks for: it asks for a tighter verdict, not a different set of transitions to distrust Arming now reads the transition only, and a confirmed replacement is held for the caller's own window, floored at 1.5s. The trigger stays exactly as on main: a complete-viewport post-action projection or a tiny presented baseline, plus fewer than half the node identities carried over. The six #1831 cases run at 500ms and 150ms alike. The provider-backed scenario is a real dialog dismissal, so it now holds the floor at its 25ms window too, which is what that wait is worth on a runner round trip: measured 1510ms over 6 captures, against 1511ms over 6 for the same scenario at the default window on main — the same verdict at the same price, no longer window-dependent.
…ays it `--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge answers well inside it, so the loop spends a whole extra capture holding a window the tree has already satisfied — and the window is paid twice per settled command: once as the settle's final window, then again as the next command's baseline capture. The loop now prices the window from the capture it performs rather than the one the action was performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any capture that declares no provenance — only the bridge carries the measurement. It re-prices on every capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports the window it ran on (`settle.quietMs`) so the response states which default it applied. Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own reads, and a stored ref frame or session tree can be several commands stale or served by another producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest` and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the general default of its own. Cadence follows the window in force, so the shorter default also samples on a grid that can measure it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass, which is what makes the re-pricing a single line rather than a fourth piece of loop state. The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off for every default settle the bridge answers.
…on alone The arming bar was `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS`, so the confirmation that #1831 added to hold a modal dismissal for 1.5s was a function of the window the caller asked for rather than of the transition in front of the loop. Three consequences, all reported as #3470: - `--settle-quiet 499` behaved differently from 500, with no rule that explains the boundary - a caller passing `settleQuietMs: 150` (tester-army/e2e) got no guard at all, on exactly the dismissal the guard exists for - a shorter window read as "trust a coherent transitional tree sooner", which is not what `--settle-quiet` asks for: it asks for a tighter verdict, not a different set of transitions to distrust Arming now reads the transition only, and a confirmed replacement is held for the caller's own window, floored at 1.5s. The trigger stays exactly as on main: a complete-viewport post-action projection or a tiny presented baseline, plus fewer than half the node identities carried over. The six #1831 cases run at 500ms and 150ms alike. The provider-backed scenario is a real dialog dismissal, so it now holds the floor at its 25ms window too, which is what that wait is worth on a runner round trip: measured 1510ms over 6 captures, against 1511ms over 6 for the same scenario at the default window on main — the same verdict at the same price, no longer window-dependent.
…ays it `--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge answers well inside it, so the loop spends a whole extra capture holding a window the tree has already satisfied — and the window is paid twice per settled command: once as the settle's final window, then again as the next command's baseline capture. The loop now prices the window from the capture it performs rather than the one the action was performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any capture that declares no provenance — only the bridge carries the measurement. It re-prices on every capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports the window it ran on (`settle.quietMs`) so the response states which default it applied. Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own reads, and a stored ref frame or session tree can be several commands stale or served by another producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest` and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the general default of its own. Cadence follows the window in force, so the shorter default also samples on a grid that can measure it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass, which is what makes the re-pricing a single line rather than a fourth piece of loop state. The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off for every default settle the bridge answers.
… on screen replacement DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE, and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting point and the device evidence, not as a shippable rule. The layer under this one arms on a complete-viewport post-action projection plus a <50% identity overlap. A tab switch and a push are exactly that: both screens project completely and the rows of one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole complaint). This narrows the trigger to the pre-action tree showing a surface presented over app content, which an interactive capture reports as the surface alone: a presented pageSheet projects 4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced stays per-capture. Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive capture and 6 through a full one, so arming survives only on the route whose baseline happens to be the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back` route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's arming when the producer answers 'unknown', and the count bound and the Alert special case both go with `showsPresentedSurface`. The three navigation cases are mutation-checked against the parent's arming: restoring the viewport-completeness arm fails exactly those three and nothing else.
…ays it `--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge answers well inside it, so the loop spends a whole extra capture holding a window the tree has already satisfied — and the window is paid twice per settled command: once as the settle's final window, then again as the next command's baseline capture. The loop now prices the window from the capture it performs rather than the one the action was performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any capture that declares no provenance — only the bridge carries the measurement. It re-prices on every capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports the window it ran on (`settle.quietMs`) so the response states which default it applied. Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own reads, and a stored ref frame or session tree can be several commands stale or served by another producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest` and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the general default of its own. Cadence follows the window in force, so the shorter default also samples on a grid that can measure it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass, which is what makes the re-pricing a single line rather than a fourth piece of loop state. The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off for every default settle the bridge answers.
… on screen replacement DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE, and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting point and the device evidence, not as a shippable rule. The layer under this one arms on a complete-viewport post-action projection plus a <50% identity overlap. A tab switch and a push are exactly that: both screens project completely and the rows of one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole complaint). This narrows the trigger to the pre-action tree showing a surface presented over app content, which an interactive capture reports as the surface alone: a presented pageSheet projects 4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced stays per-capture. Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive capture and 6 through a full one, so arming survives only on the route whose baseline happens to be the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back` route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's arming when the producer answers 'unknown', and the count bound and the Alert special case both go with `showsPresentedSurface`. The three navigation cases are mutation-checked against the parent's arming: restoring the viewport-completeness arm fails exactly those three and nothing else.
…ed quiet window (#3512) * fix(settle): decide the broad-transition confirmation by the transition alone The arming bar was `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS`, so the confirmation that #1831 added to hold a modal dismissal for 1.5s was a function of the window the caller asked for rather than of the transition in front of the loop. Three consequences, all reported as #3470: - `--settle-quiet 499` behaved differently from 500, with no rule that explains the boundary - a caller passing `settleQuietMs: 150` (tester-army/e2e) got no guard at all, on exactly the dismissal the guard exists for - a shorter window read as "trust a coherent transitional tree sooner", which is not what `--settle-quiet` asks for: it asks for a tighter verdict, not a different set of transitions to distrust Arming now reads the transition only, and a confirmed replacement is held for the caller's own window, floored at 1.5s. The trigger stays exactly as on main: a complete-viewport post-action projection or a tiny presented baseline, plus fewer than half the node identities carried over. The six #1831 cases run at 500ms and 150ms alike. The provider-backed scenario is a real dialog dismissal, so it now holds the floor at its 25ms window too, which is what that wait is worth on a runner round trip: measured 1510ms over 6 captures, against 1511ms over 6 for the same scenario at the default window on main — the same verdict at the same price, no longer window-dependent. * fix(settle): sample an extended confirmation on its own cadence Poll cadence derived from the requested window once, before the loop ran, so the 1.5s confirmation this branch extends that window to was spent sampling at the caller's grid. At `--settle-quiet 25` that is 109 capture round trips to reach a verdict the default grid reaches in 10 — a confirmation whose cost is 11x the reads it needs, on the tightest windows where settle is asked for at all. Cadence now follows the window in force. Both directions hold: a tight window still gets a grid that can measure it, and an extended wait gets the default grid rather than the caller's. Pinned by the capture count in the 25ms case: 10 here, 109 with the recompute removed (mutation-checked).
…ays it `--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge answers well inside it, so the loop spends a whole extra capture holding a window the tree has already satisfied — and the window is paid twice per settled command: once as the settle's final window, then again as the next command's baseline capture. The loop now prices the window from the capture it performs rather than the one the action was performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any capture that declares no provenance — only the bridge carries the measurement. It re-prices on every capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports the window it ran on (`settle.quietMs`) so the response states which default it applied. Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own reads, and a stored ref frame or session tree can be several commands stale or served by another producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest` and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the general default of its own. Cadence follows the window in force, so the shorter default also samples on a grid that can measure it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass, which is what makes the re-pricing a single line rather than a fourth piece of loop state. The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off for every default settle the bridge answers.
… on screen replacement DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE, and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting point and the device evidence, not as a shippable rule. The layer under this one arms on a complete-viewport post-action projection plus a <50% identity overlap. A tab switch and a push are exactly that: both screens project completely and the rows of one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole complaint). This narrows the trigger to the pre-action tree showing a surface presented over app content, which an interactive capture reports as the surface alone: a presented pageSheet projects 4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced stays per-capture. Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive capture and 6 through a full one, so arming survives only on the route whose baseline happens to be the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back` route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's arming when the producer answers 'unknown', and the count bound and the Alert special case both go with `showsPresentedSurface`. The three navigation cases are mutation-checked against the parent's arming: restoring the viewport-completeness arm fails exactly those three and nothing else.
…ays it `--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge answers well inside it, so the loop spends a whole extra capture holding a window the tree has already satisfied — and the window is paid twice per settled command: once as the settle's final window, then again as the next command's baseline capture. The loop now prices the window from the capture it performs rather than the one the action was performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any capture that declares no provenance — only the bridge carries the measurement. It re-prices on every capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports the window it ran on (`settle.quietMs`) so the response states which default it applied. Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own reads, and a stored ref frame or session tree can be several commands stale or served by another producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest` and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the general default of its own. Cadence follows the window in force, so the shorter default also samples on a grid that can measure it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass, which is what makes the re-pricing a single line rather than a fourth piece of loop state. The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off for every default settle the bridge answers.
… on screen replacement DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE, and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting point and the device evidence, not as a shippable rule. The layer under this one arms on a complete-viewport post-action projection plus a <50% identity overlap. A tab switch and a push are exactly that: both screens project completely and the rows of one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole complaint). This narrows the trigger to the pre-action tree showing a surface presented over app content, which an interactive capture reports as the surface alone: a presented pageSheet projects 4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced stays per-capture. Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive capture and 6 through a full one, so arming survives only on the route whose baseline happens to be the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back` route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's arming when the producer answers 'unknown', and the count bound and the Alert special case both go with `showsPresentedSurface`. The three navigation cases are mutation-checked against the parent's arming: restoring the viewport-completeness arm fails exactly those three and nothing else.
…n, and pin the parity gap The review on this PR found that padding the provider-scenario dialog past the arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test that checked the 1.5s confirmation through the daemon: the transition stopped being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become `captures in 2..3`. Revert that padding, which restores the unpadded dialog and its original assertion; all four provider-scenario cases pass on this rule. Add the parity case the review asked for: the same presented sheet dismissed from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a full-tree read of the same screen (6 nodes), both asserted to confirm. Measured on the injected clock, the interactive baseline confirms for 1,502ms and the full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost on the route whose baseline happens to be the richer flavour. Marked `fails`, so the lane stays green and the case turns red ("expected to fail but passed") the moment #3565's `presentedSurface` replaces the count bound: that red is the signal to drop the marker, and the assertion is the rewrite's acceptance test.
…n, and pin the parity gap The review on this PR found that padding the provider-scenario dialog past the arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test that checked the 1.5s confirmation through the daemon: the transition stopped being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become `captures in 2..3`. Revert that padding, which restores the unpadded dialog and its original assertion; all four provider-scenario cases pass on this rule. Add the parity case the review asked for: the same presented sheet dismissed from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a full-tree read of the same screen (6 nodes), both asserted to confirm. Measured on the injected clock, the interactive baseline confirms for 1,502ms and the full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost on the route whose baseline happens to be the richer flavour. Marked `fails`, so the lane stays green and the case turns red ("expected to fail but passed") the moment #3565's `presentedSurface` replaces the count bound: that red is the signal to drop the marker, and the assertion is the rewrite's acceptance test.
Summary
Fix iOS interactive snapshots so private-AX transition state is derived from the visible presentation before semantic scoping.
This projects hidden/offscreen transition content out of the actionable tree, preserves scroll hints, promotes conservative app-provided navigation-title affordances, and distinguishes collapsed from expanded action shelves using viewport geometry. It also keeps raw iOS, macOS, and Android scope pushdown unchanged.
Stable capture now confirms broad screen replacements for 1.5 seconds, including replacements that begin after the first post-action capture or recover from tree to private AX. The confirmation applies only to XCTest captures using the default quiet period; explicit short quiet periods and non-transition captures retain their existing timing. This prevents
--settlefrom accepting a self-consistent intermediate Element tree.The change also hardens Apple runner source packaging and PR XCTest selection so the coordinate text-input policy exercised in source is included in the runner that ships and executes before merge.
Scope disclosure: 25 files are touched across four connected parts of the same iOS capture boundary: private-AX presentation and fixtures, scoped snapshot capture, stable-capture transition confirmation (including tree-to-private-AX recovery), and Apple runner packaging/PR test selection. No Android behavior, public CLI surface, docs, or skills changed.
Validation
origin/mainat4bba40424(fix(layering): keep the snapshot interactor seam out of the type cycle #1838); pushed head is5b81b67e6.pnpm check:affected --run: package, build, type, layering, lint, format, Fallow, metadata, and Node integration gates passed. Affected coverage passed 2,502/2,503 tests; the sole failure was an unchanged iOS recorder-provider test timing out in/bin/psunder concurrent host XCTest load.origin/mainunder identical load (2/2 failures), including the same incomplete process-identity error, classifying it as baseline/host contention rather than a PR regression.isEnabledfailed 1/1 at the new assertion; restored code passed 1/1 in 0.017 seconds.tree -> private-axrecovery and a replacement beginning after the first capture failed by settling on the transitionalaction file; the fix passes all 17 focused stable-capture tests.pnpm check:quickpasses.2a1b036b14bab4cd8c692b85e0350db85820c6173b1355ac361db71ac505e04a; the installed daemon bundle matches the branch build byte-for-byte.