Skip to content

feat(ios): the snapshot node carries the field's placeholder - #2961

Merged
thymikee merged 3 commits into
callstack:mainfrom
okwasniewski:oskar/ios-placeholder
Sep 25, 2026
Merged

thymikee merged 3 commits into
callstack:mainfrom
okwasniewski:oskar/ios-placeholder

Conversation

@okwasniewski

@okwasniewski okwasniewski commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Android nodes have carried the field's hint as placeholder since #2927; iOS nodes carried nothing. A consumer could neither find a field by its placeholder nor tell an empty field from one holding that text, since XCTest reports an empty field's placeholder as its value. XCTest exposes placeholderValue on every element and snapshot, and the AX server exposes XC_kAXXCAttributePlaceholderValue.

Every iOS producer now publishes placeholder on RawAXNode and PresentedNode, absent when empty:

  • the XCTest tree and the query sweeps read placeholderValue off the snapshot or element (SnapshotEvaluation carries it);
  • the runner's private-AX reader asks for the placeholderValue keypath beside value;
  • the Simulator AX bridge requests the attribute as optional: a runtime whose vocabulary lacks it serves the capture without placeholders instead of failing it; the source version moves to v1.8.0, so each host recompiles the bridge once.

The element sweeps read placeholderValue on text-entry types only (a live XCUIElement attribute is one lookup per element inside the sweep's deadline); the snapshot producer reads it off the snapshot it already holds.

agent-device snapshot --raw --json | jq '.data.nodes[] | select(.identifier=="name-input") | {value, placeholder}'
# {"value":"Ada Lovelace","placeholder":"Type your name"}

The kernel field, the unchanged-digest fields, and the response views already knew placeholder from #2927, so nothing on the wire changes shape. 17 files: 9 Swift/ObjC, 5 TypeScript and fixtures, 3 tests and docs.

Validation

Tested at 9ef008834f (third commit: optional bridge attribute, trimmed placeholder, text-entry-only sweep reads; pnpm check:affected --run --base c31cc49f6 passed on it, and the three host-lane XCTests pass on the rebuilt runner):

  • pnpm check:affected --run --base c31cc49f6: all runnable checks passed, check:xctest-selection and check:packaged-runner-swift among them.
  • Host-lane XCTest on an iOS 26.5 simulator (Xcode 26.6 Build version 17F113 ): testSnapshotPresentationPreservesCurrentWireShape (the wire shape now names placeholder) and the new testPrivateAXAcquisitionCarriesTheFieldPlaceholder pass; pnpm test:ios-snapshot-differential passes.
  • tree.test.ts pins the bridge mapping and that an empty attribute is omitted; protocol.test.ts pins the v1.8.0 vocabulary in the native source.
  • Live, dev daemon, dev.e2e.benchmark Control Inventory, on the rebuilt runner: snapshot --actions --json pins the private-AX tier (snapshotQuality.backend: private-ax, reasonCode: requested-backend); the runner log shows AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_USED nodes=58 depth=64 for that capture, and name-input carries value: "Ada Lovelace" beside placeholder: "Type your name", with passphrase-input and key-input carrying theirs. The XCTest tree producer on an iPhone 17 Pro reports placeholder on the three fields (Type your name, Passphrase, Key echo), a filled field included; the v1.8.0 bridge on an iPhone 17 Pro Max reports the same field with placeholder: "Type your name", role: RCTUITextField, value: "Ada Lovelace".
  • Risk: a runtime whose AX vocabulary lacks XC_kAXXCAttributePlaceholderValue fails the bridge's existing vocabulary check and falls back to the runner, as any unknown attribute does today.

Android nodes carry the field's hint as placeholder since callstack#2927; iOS nodes carried
nothing, so a consumer could neither query a field by its placeholder nor tell an
empty field (XCTest reports the placeholder as its value) from one holding that
text. XCTest exposes placeholderValue on every element and snapshot, and the AX
server exposes XC_kAXXCAttributePlaceholderValue.

Every iOS producer now publishes placeholder on RawAXNode and PresentedNode: the
XCTest tree and query sweeps read placeholderValue off the snapshot or element,
the runner's private-AX reader asks for the keypath beside value, and the
Simulator AX bridge requests the attribute (source v1.8.0, one recompile per
host). An empty placeholder reads as none. The docs name the fact for both
platforms.
Copilot AI lite review requested due to automatic review settings September 25, 2026 09:20

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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

Copy link
Copy Markdown

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 17 files

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

Fix all with cubic | Re-trigger cubic

Comment thread website/docs/docs/snapshots.md Outdated
Comment thread packages/platform-apple/src/snapshot-source/tree.test.ts Outdated
@thymikee

Copy link
Copy Markdown
Member

Commit 80aa8df: the private-AX reader now sends placeholderValue through axAttributesForElementSnapshotKeyPaths:isMacOS: and reads it back with valueForKey: (https://github.com/callstack/agent-device/blob/80aa8df/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m#L368). The only test for this, testPrivateAXAcquisitionCarriesTheFieldPlaceholder, starts from a hand-built raw dictionary, so it skips the ObjC mapper, the suffix filter, and the AX server, and the live evidence in this PR only covers the XCTest tree and the Simulator bridge. That leaves the private-AX route itself unverified on device, and this route is what the deep React Native tree fallback uses (SnapshotCapturePlan.swift:494), so nothing here shows it still captures, or returns placeholder, once the new keypath is in the request. Please add a live capture served by .privateAX, for example a deep RN tree that triggers the depth ladder, and check that the runner log shows privateAXSnapshotAcquisition served it; the output should show a text-field node carrying placeholder separate from value.

Not blocking, can be taken or left: is kAttributePlaceholderValue safe in the required attribute vocabulary at SnapshotBridgeRuntime.m:334, or should it follow the optional-attribute path kAttributeIsUserInteractionEnabled already uses at line 357 so a runtime missing this attribute doesn't fail the whole capture; the docs at website/docs/docs/snapshots.md:455 should say that value equal to placeholder means the field is empty or holds that exact text, since a user can type the placeholder itself; the private-AX reader and the bridge's optionalString should trim placeholder whitespace the way the XCTest producers already do (RunnerTests+PrivateAXPresentation.swift:75); and is reading element.placeholderValue inside the flat query sweep and the collapsed-tab sweep worth a live XCUIElement lookup per element inside the sweep's deadline (RunnerTests+SnapshotAcquisition.swift:573), or should it come from the already-resolved snapshot instead?

I did not re-run the host-lane XCTest, the snapshot differential, or the live Control Inventory captures the PR body cites. I can't tell whether XCElementSnapshot's keypath mapper actually maps placeholderValue, or whether adding it changes what's mapped for the other attributes, and I don't know which iOS runtimes lack XC_kAXXCAttributePlaceholderValue, so the vocabulary-mismatch risk above is unquantified. Smoke Tests (both) and Coverage were still running when I looked; the iOS smoke job builds the runner and runs snapshot captures, and since this diff touches the runner snapshot producers, the private-AX keypath set, and the bridge source version (forcing a bridge recompile), a failure there should count as related until its log says otherwise. Before this merges, someone needs a live .privateAX capture showing a node with placeholder, plus green Smoke Tests and Coverage.

…apart in the tests

Review follow-ups: the collapsed-tab producer read the placeholder only after
its content check, so a placeholder-only field inside a collapsed container was
dropped; the placeholder now counts as content. The bridge test used one string
for value and placeholder, so it could not tell the two attributes apart; it
uses distinct strings and asserts both. A wire-shape test pins that a node
without a placeholder encodes no key. The docs stop calling a value equal to the
placeholder an empty field, since a user can type that text.
Copilot AI review requested due to automatic review settings September 25, 2026 09:45

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…ulary, read it on text entry only

Review follow-ups. The Simulator AX bridge asked for the placeholder attribute
in its required set, so a runtime whose vocabulary lacks it would have failed
every capture; a mismatch now retries the mapping without the attribute and the
capture serves without placeholders. The bridge trims the placeholder the way
the runner does, so whitespace reads as none. The element sweeps read a live
XCUIElement attribute per element inside their deadline, so they read
placeholderValue on text-entry types only; the snapshot producer keeps reading
it off the snapshot it already holds.
Copilot AI review requested due to automatic review settings September 25, 2026 10:19
@okwasniewski

Copy link
Copy Markdown
Contributor Author

[claude-fable-5-1] responding on behalf of Oskar

Thanks. Live .privateAX capture, on the runner rebuilt from 9ef0088, dev.e2e.benchmark Control Inventory on an iPhone 17 Pro simulator (iOS 26.5): snapshot --actions --json pins the private-AX tier; the response says snapshotQuality.backend: private-ax, state: recovered, reasonCode: requested-backend, and the session runner log shows AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_USED nodes=58 depth=64 extended=0 for that capture. The name-input node carries value: "Ada Lovelace" and placeholder: "Type your name"; passphrase-input and key-input carry Passphrase and Key echo. So the mapper maps placeholderValue, and the other attributes still arrive on the same nodes (label, value, frame, enabled, selected, focus).

Optional items, all taken in fd3ae05 and 9ef0088: the bridge treats the placeholder attribute as optional (a vocabulary mismatch retries the mapping without it, so a runtime lacking it serves without placeholders); the doc says a value equal to the placeholder is an empty field or one holding that text; the bridge trims the placeholder like the runner (the private-AX reader already trimmed through privateAXPresentationString); and the element sweeps read placeholderValue on text-entry types only, the snapshot producer keeps reading it off the snapshot it already holds.

pnpm check:affected --run --base c31cc49f6 passed on 9ef0088; Smoke Tests, Integration Tests, and Coverage were green on 80aa8df and rerun on this head.

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thymikee

Copy link
Copy Markdown
Member

The code looks right at 9ef0088. The placeholder now round-trips through the snapshot node the way the earlier review (#2961 (comment)) asked for, and there are no conflicts.

Not blocking: could the collapsed-tab sweep at https://github.com/callstack/agent-device/blob/9ef0088/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests%2BSnapshotAcquisition.swift#L402 drop its placeholder read and the || placeholder != nil term in hasContent, since collapsedTabCandidateTypes never overlaps placeholderElementTypes and the read is always nil at that site, and is a host-lane test worth adding for the retry-without-placeholder branch at https://github.com/callstack/agent-device/blob/9ef0088/apple/snapshot-bridge/SnapshotBridgeRuntime.m#L342, or is leaving both as-is fine since no runtime lacking the attribute is known to exist?

I did not re-run the live .privateAX capture; I took the reported backend, runner log line, and name-input value/placeholder pair as stated. I could not exercise the bridge's vocabulary-mismatch fallback, because no runtime lacking XC_kAXXCAttributePlaceholderValue was available. I did not run the host-lane XCTest unit tests; testPresentedNodeOmitsAnAbsentPlaceholder guards the encoder contract and would also pass on the pre-change code, which is expected since it's a contract pin, not a regression test.

Three Smoke Tests jobs, Coverage, and Integration are green. One Smoke Tests job (run 36123366332) is still pending, and since the iOS smoke route builds the runner and bridge and runs snapshot captures, which this diff changes, its result counts as related until the log says otherwise; nothing has failed so far. That job finishing green is the only thing left.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 25, 2026
@thymikee
thymikee merged commit 5712a35 into callstack:main Sep 25, 2026
16 of 17 checks passed
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.

3 participants