Skip to content

fix(ios): mark zero-area nodes unmeasured and point their press refusal at scroll --until - #4495

Merged
thymikee merged 5 commits into
callstack:mainfrom
ruizemanuel:fix/ios-unmeasured-offscreen-nodes
Nov 12, 2026
Merged

thymikee merged 5 commits into
callstack:mainfrom
ruizemanuel:fix/ios-unmeasured-offscreen-nodes

Conversation

@ruizemanuel

@ruizemanuel ruizemanuel commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #4451.

  • Snapshot: buildSnapshotState marks a node published with a zero-area frame as [unmeasured], next to the covered annotation, so every producer gets it. Regular snapshots only; --raw is unchanged.
  • Recovery: the press/click: refuse a zero-area target instead of tapping its origin #3841 refusal is unchanged (target_bounds_invalid, not dispatched). When a scrollable sibling is listed before such a target, the refusal adds scrollContainerRef and a hint naming scroll <direction> --until <selector>, with no direction.

9 files. Scope expansion: the off-screen hint now quotes its selector with shellQuote too; an apostrophe broke its command.

Not covered: under a framed scroll ancestor the off-screen stage still refuses first, with scrollDirection: "up" read from the 0×0 frame.

Validation

Base eec35e9, head a91a366.

  • vitest run snapshot-state.test.ts resolution-touch-point.test.ts target-visibility-stages.test.ts: base 6 failed, 15 passed; head 21 passed.
  • pnpm check:affected --run --head a91a366 (exactCommit: true): failed at unit, 1 of 19,494: daemon-runtime-composition.test.ts:86, identical on an isolated base checkout; no daemon file touched. Host cause unproven; CI owns unit. The runner stops there; by hand, check:doc-prose and check:doc-links (Node 24.21.0) passed.
  • iOS 26.2 simulator, the issue's Flutter repro, press 'label="View all"':
    • base: @e6 [button] "View all"; Next: Read the diagnostics log for the failing step.
    • head: @e6 [button] "View all" [unmeasured]; Next: … run scroll <direction> --until 'label="View all"' …. Following it, the press taps.
  • Marked nodes in full snapshots are counted in the review reply. Not run: Appium, Revyl, physical devices, macOS, Linux.
  • Independent review, four rounds; Cubic's findings answered in-thread.

…al at scroll --until

A scroll view can keep its scrolled-out content in the tree with a zero-area
frame (Flutter does on iOS). The Simulator AX bridge fold kept such a node as
if it were on screen, and pressing one was refused with no recovery.

The fold now marks such a node [unmeasured]. A target with no usable frame is
still refused; when a scrollable sibling is listed before it, which is how
Flutter publishes a scroll view and its content, the refusal names
scroll --until with no direction, since the frame gives none.

Fixes callstack#4451

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/capture-kit/src/ios-snapshot-engine/geometry.ts">

<violation number="1" location="packages/capture-kit/src/ios-snapshot-engine/geometry.ts:154">
P2: This fold runs for every acquired iOS producer, so Appium and Revyl trees also label child nodes with unusable frames as `[unmeasured]`, although this behavior is documented as Simulator AX-specific. Pass producer provenance into the fold and apply the hint only for `simulator-ax-bridge`.</violation>
</file>

Reply to a comment to ask cubic a question or push back. It learns from your replies.

View guided diff | Re-trigger cubic

…n the unmeasured recovery

The hint serializes the selector as a double-quoted argument, so one holding
an apostrophe survives. The ref form names snapshot -i, as the off-screen ref
hint does. A scroll bar listed before the target is no longer read as its
scroll container.

The help and docs say where the mark is added: every iOS snapshot presented
from an acquired tree, not only the Simulator AX bridge.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread website/docs/docs/snapshots.md Resolved
…he unmeasured mark to regular snapshots

The recovery hint wrapped the selector with JSON.stringify, and inside double quotes a shell still expands $, backticks and !. It now uses a POSIX single-quote wrap. The help and snapshots.md say the [unmeasured] mark belongs to regular snapshots: --raw returns before the fold.
@thymikee

Copy link
Copy Markdown
Member

Reviewed 2cc05f3. I found no blocking code problems, but the evidence is incomplete: the Appium and Revyl provider routes were not run, and nobody measured how many zero-size nodes native iOS apps publish. I ran no tests and no device. The "base 2 failed, head 23 passed" count and the Flutter before/after output are the author's and I did not reproduce them.

The ownership question comes first. The mark is applied only in the TypeScript fold (https://github.com/callstack/agent-device/blob/2cc05f3/packages/capture-kit/src/ios-snapshot-engine/geometry.ts#L171), so it reaches the bridge and provider routes. Simulators and physical devices use XCTest through the Swift presenter, which has no equivalent, and the differential harness omits presentationHints, so this gap passes silently. Would it be simpler to add 'unmeasured' to presentationHints in the producer-neutral pass in buildSnapshotState, next to annotateCoveredSnapshotNodes? That would cover XCTest, the bridge and both providers with no Swift change, and the provider-provenance question in the open thread goes away. Android and macOS zero-rect nodes would get the mark too, which looks correct. Either the mark is part of the shared Swift/TS presentation contract (change both engines and the conformance fixture) or it is a producer-neutral annotation (move it); the PR currently picks neither.

Not blocking, take or leave: the press recovery builds its own shell quoting, so please use shellQuote from kernel instead. The scroll hint is built twice with different wording and detail keys (throwIfOffscreenInteractionTarget and scrollContainerRecovery), and scrollContainerRef keeps the '@' while details.ref is bare by convention. The mark fires for every kept non-root node without a positive finite rect, so a before/after full snapshot of Settings on the bridge route with the count of newly marked nodes would show the noise level; if non-interactive nodes are marked, limit it to nodes with semantic content or interactive nodes. unmeasuredHint re-derives "no frame" with isPositiveFiniteRect, while traversalDecision already computes hasFrame, so carrying unmeasured on GeometryDecision would remove the duplicate if the mark stays in the fold.

On the open review threads: #4495 (comment) (provider provenance) is moot because the docs now match the code, and it ends if the mark moves. #4495 (comment) (hint overwrite) does not apply because nothing upstream sets hints on iOS nodes, and #4495 (comment) (scroll indicator) is fixed at this head. Please resolve all three.

CI is green with 1 check and none failing, and I know of no conflicts. The local check:affected unit failure in daemon-runtime-composition.test.ts is not reproduced in CI, and this diff touches no daemon file. Before merge, answer the ownership question and switch to shellQuote.

…and quote scroll hints with shellQuote

The unmeasured mark moves from the iOS acquired-tree fold to buildSnapshotState, next to the covered annotation, so every producer gets it: the XCTest runner, the Simulator AX bridge, the provider trees, Android and desktop. Regular snapshots only, as before.

The zero-area recovery takes its selector already quoted from the command-runtime caller, which uses the kernel's shellQuote; the selectors entry cannot import it without growing its eager closure. The off-screen hint uses the same helper, so a selector with an apostrophe no longer breaks its command. scrollContainerRef is the bare ref body, like details.ref.
@ruizemanuel

Copy link
Copy Markdown
Contributor Author

Pushed 2addea4.

Ownership: moved, as you proposed. unmeasured is now added in buildSnapshotState, right after annotateCoveredSnapshotNodes, to any node published with a rect that is not positive and finite. The fold and its tests are back to their base content. It applies to regular snapshots only, like covered. A new test runs the #4451 tree through buildSnapshotState with apple-runner and android-uiautomator provenance.

Noise, measured at this head (full snapshot; iOS 26.2 simulator, API 37 emulator, managed web backend):

Screen Route Nodes Marked
Flutter repro at the top Simulator AX bridge 8 3
Settings, General, General scrolled Simulator AX bridge 51, 84, 37 0
SpringBoard XCTest (the bridge refused: target-resolution-failed) 66 12
Android Settings, Settings scrolled, Flutter repro UIAutomator 31, 32, 14 0
Web: a Wikipedia article, this repository's GitHub page agent-browser 2236, 916 0

The 3 on the repro are the scrolled-out content. The 12 on SpringBoard are all Element(44) icons with a label and a 0×0 rect at the origin: the home-screen page that is not shown, which is the #3841 case. At 2cc05f3 the same XCTest capture marked none. Every marked node has a label, so I added no interactive or semantic filter.

One limit of reading the published rect: both iOS presenters publish a zero-size rect for an application or window they keep while it is fully clipped, so such a node would get the mark too. None appeared in these captures.

shellQuote: the recovery now uses the kernel's shellQuote. resolution.ts in command-runtime calls it and passes the result to resolveNodeTouchPoint as shellQuotedSelector. I could not import it in packages/selectors/src/node-touch-point.ts: that file is a package entry, and device-shell.ts in its closure fails eager-closure-budgets.test.ts. If you prefer the direct import, it needs an APPROVED_CLOSURE_ADDITIONS row from you.

Also taken:

  • scrollContainerRef is the bare ref body.
  • The off-screen hint in target-visibility-stages.ts quotes with shellQuote as well. It wrapped the selector in bare single quotes, so label="Today's cash" broke its command; a test covers it. The two hints still have separate wording.

Not run: the Appium and Revyl routes (no backend here), physical devices, macOS and Linux.

Local unit: daemon-runtime-composition.test.ts:86 still fails on this host, the same on base. The PR's workflows show action_required, so they have not run for this branch yet.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 11 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

…it from ref targets

The zero-area recovery quotes its selector with shellQuoteIfNeeded, as the moving-target gate does, so a bare selector stays bare. The ref arm of the failure type forbids shellQuotedSelector, so a ref target cannot be handed the selector form of the hint.
@thymikee

Copy link
Copy Markdown
Member

I reviewed a91a366 against the code and found no remaining problems. Both earlier review threads are fixed at this head: resolution.ts now uses shellQuoteIfNeeded with a matching apostrophe test, and node-touch-point.ts types shellQuotedSelector as never on the ref arm and required on the selector arm. Please resolve those two threads (#4495 (comment) and #4495 (comment)).

The evidence gap from the earlier review is closed. The simulator before/after output and the noise table are yours, and I checked them against the code only. I ran no tests, gate or device. I did not run the eager-closure budget. I only confirmed that device-shell.ts is outside the node-touch-point closure and that the rule is no-growth, so threading shellQuotedSelector makes sense. The Appium, Revyl, physical-device, macOS and Linux routes share buildSnapshotState and resolveNodeTouchPoint, so I see no separate code path, but their zero-rect node counts are unmeasured.

CI is not done. The one reported check passes, but the repo CI jobs (unit, typecheck, layering and closure gates) show action_required for this fork PR and have not run on this head. This diff touches the closure-gated selectors/node-touch-point entry and the command-surface help text, so those gates need a real run. Your local daemon-runtime-composition.test.ts:86 failure is identical on base and touches no daemon file.

A maintainer must approve the fork CI run, and the checks must pass before merge. One small note: the off-screen hint still uses always-quoting shellQuote, while the new recovery uses shellQuoteIfNeeded. A bare id=foo selector renders quoted in one refusal and bare in the other. Aligning them is optional.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Nov 12, 2026
@thymikee
thymikee merged commit 0495ee1 into callstack:main Nov 12, 2026
15 of 16 checks passed
thymikee added a commit that referenced this pull request Nov 13, 2026
…baseline

* origin/main:
  ci: use smaller runners for control and publishing jobs (#4492)
  docs(help): point verification at read-only commands, not bare find (#4497)
  fix(android): let open --relaunch accept a package Android restarts at once (#4498)
  fix(snapshot): name the captured Android app in the snapshot header (#4499)
  feat(proxy): configure trusted public upload origins (#4501)
  fix(android): restore screenshot demo permission state (#4477)
  feat(harmonyos): support app uninstall beside deploy (#4292)
  fix(ios): confirm foldable rotation from the lit panel in either simulator set (#4343) (#4440)
  chore: mark 0.21.37 as released
  fix(ios): make back the verified left-edge swipe (#4460)
  fix(ios): mark zero-area nodes unmeasured and point their press refusal at scroll --until (#4495)
  fix(ios): never repeat a synthesized gesture that may have run (#4474)
  fix(capture): publish cropped screenshots atomically after scaling (#4478)
  ci(android): reuse cached system images across emulator workflows (#4490)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: off-screen nodes with a zero-size rect are listed as on-screen, and pressing one does not point at scroll --until (Flutter repro)

2 participants