Repository navigation
fix(ios): give simulator AX bridge captures a tree-bound keyboard fact #4217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4f5a9e0
0c987a6
4d0176d
05c8da9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'agent-device': patch | ||
| --- | ||
|
|
||
| Fix the first read after a drag that only shows or hides the soft keyboard waiting about 3.5 s on the iOS Simulator AX bridge, the default Simulator route. A bridge capture now publishes a keyboard fact read from its own tree: the docked-tree band when the tree holds a docked keyboard, and `absent` only when the tree is complete and holds no keyboard window, `Keyboard` or `Key` node, or keyboard role. A truncated tree, or a keyboard window that exposes no keys (for example, a hardware keyboard that shows only the assistant bar), stays `unmeasurable`. | ||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -66,6 +66,8 @@ export const TAP_KEYBOARD_OCCLUDES_TARGET_DETAILS = Object.freeze({ | |||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| const IOS_KEYBOARD_TYPE_NAMES: ReadonlySet<string> = new Set(['keyboard', 'key']); | ||||||||||||
| /** The system window that hosts the remote keyboard, with or without keys in it. */ | ||||||||||||
| const IOS_KEYBOARD_WINDOW_ROLE = 'UIRemoteKeyboardWindow'; | ||||||||||||
|
|
||||||||||||
| /** The occluding band of the visible keyboard, plus the rects that prove a tap meant the keyboard. */ | ||||||||||||
| export type KeyboardSurface = { | ||||||||||||
|
|
@@ -356,6 +358,31 @@ export function acquireProviderKeyboard(params: ProviderKeyboardInput): { | |||||||||||
| }; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * The keyboard fact of a producer that reads the target app's whole in-process tree in one capture, | ||||||||||||
| * as the Simulator AX bridge does: the keyboard window is one of the app's own windows there, so a | ||||||||||||
| * complete tree with no keyboard window and no keyboard node proves absence the way a full XCTest scan | ||||||||||||
| * does. A truncated tree, or a keyboard window that exposes no keys, proves only presence. | ||||||||||||
| */ | ||||||||||||
| export function observeCompleteTreeKeyboard( | ||||||||||||
| params: Readonly<{ nodes: readonly RawSnapshotNode[]; viewport: Rect | null; complete: boolean }>, | ||||||||||||
| ): SnapshotKeyboardBandFact { | ||||||||||||
| const fact = observeProviderKeyboard(params); | ||||||||||||
| if (fact.kind !== 'unmeasurable' || !params.complete) return fact; | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This returns a Prompt for AI agents
Suggested change
|
||||||||||||
| return params.nodes.some(isKeyboardTraceNode) ? fact : { kind: 'absent', treeBound: true }; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /** Any node the keyboard leaves in the tree, including its window and the in-app placeholder. */ | ||||||||||||
| function isKeyboardTraceNode(node: RawSnapshotNode): boolean { | ||||||||||||
| return ( | ||||||||||||
| node.role === IOS_KEYBOARD_WINDOW_ROLE || | ||||||||||||
| isKeyboardSurfaceNode(node) || | ||||||||||||
| hasIosKeyboardRole(node, IOS_KEYBOARD_SETTLE_CHROME_ROLES) || | ||||||||||||
| hasIosKeyboardRole(node, IOS_KEYBOARD_SUBTREE_CHROME_ROLES) || | ||||||||||||
| hasIosKeyboardRole(node, IOS_KEYBOARD_CHROME_MARKER_ROLES) | ||||||||||||
| ); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function observeProviderKeyboard(params: ProviderKeyboardInput): SnapshotKeyboardBandFact { | ||||||||||||
| const frame = deriveDockedKeyboardFrame(params.nodes, params.viewport); | ||||||||||||
| // Derived from the nodes this capture returns, so a stale tree derives the stale tree's band. | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| /** | ||
| * Guest trees for the Simulator AX source's keyboard fact: each case names the windows the fake | ||
| * guest reports under its root, whether it cut the tree, and the fact the capture must publish. | ||
| */ | ||
|
|
||
| /** A guest node; `automationType` 19 is `Keyboard` and 20 is `Key`. */ | ||
| function guestNode( | ||
| elementType: string, | ||
| frame: Readonly<{ X: number; Y: number; Width: number; Height: number }>, | ||
| children: readonly Record<string, unknown>[] = [], | ||
| automationType?: number, | ||
| ): Record<string, unknown> { | ||
| return { | ||
| XC_kAXXCAttributeElementType: elementType, | ||
| ...(automationType === undefined ? {} : { XC_kAXXCAttributeAutomationType: automationType }), | ||
| XC_kAXXCAttributeFrame: frame, | ||
| XC_kAXXCAttributeChildren: children, | ||
| }; | ||
| } | ||
|
|
||
| const APP_FRAME = { X: 0, Y: 0, Width: 390, Height: 844 }; | ||
| const ASSISTANT_FRAME = { X: 0, Y: 509, Width: 390, Height: 44 }; | ||
| // The docked key plane stops 58 pt above the viewport bottom, as on iPhone 17 Pro. | ||
| const DOCKED_KEYBOARD_WINDOW = guestNode('UIRemoteKeyboardWindow', APP_FRAME, [ | ||
| guestNode('TUISystemInputAssistantView', ASSISTANT_FRAME), | ||
| guestNode( | ||
| 'UIKeyboardAutomatic', | ||
| { X: 0, Y: 553, Width: 390, Height: 233 }, | ||
| [ | ||
| guestNode('UIKeyboardLayoutStar', { X: 0, Y: 553, Width: 390, Height: 233 }, [ | ||
| guestNode('UIAccessibilityElementKBKey', { X: 0, Y: 560, Width: 195, Height: 54 }, [], 20), | ||
| guestNode( | ||
| 'UIAccessibilityElementKBKey', | ||
| { X: 195, Y: 560, Width: 195, Height: 54 }, | ||
| [], | ||
| 20, | ||
| ), | ||
| ]), | ||
| ], | ||
| 19, | ||
| ), | ||
| ]); | ||
|
|
||
| const notObserved = { kind: 'unmeasurable', reason: 'keyboard-not-observed' }; | ||
|
|
||
| export const ADAPTER_KEYBOARD_CASES: readonly Readonly<{ | ||
| name: string; | ||
| windows: readonly Record<string, unknown>[]; | ||
| truncated: boolean; | ||
| keyboard: Record<string, unknown>; | ||
| }>[] = [ | ||
| { | ||
| name: 'a docked keyboard', | ||
| windows: [DOCKED_KEYBOARD_WINDOW], | ||
| truncated: false, | ||
| keyboard: { | ||
| kind: 'derived', | ||
| derivation: 'docked-tree', | ||
| frame: { x: 0, y: 553, width: 390, height: 291 }, | ||
| treeBound: true, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'a complete tree', | ||
| windows: [], | ||
| truncated: false, | ||
| keyboard: { kind: 'absent', treeBound: true }, | ||
| }, | ||
| // A cut can drop the keyboard window, so a truncated tree proves only presence. | ||
| { name: 'a truncated tree', windows: [], truncated: true, keyboard: notObserved }, | ||
| // The in-app placeholder can outlive the key plane during a dismissal. | ||
| { | ||
| name: 'a placeholder without keys', | ||
| windows: [ | ||
| guestNode('UITextEffectsWindow', APP_FRAME, [ | ||
| guestNode('_UIRemoteKeyboardPlaceholderView', { X: 0, Y: 509, Width: 390, Height: 335 }), | ||
| ]), | ||
| ], | ||
| truncated: false, | ||
| keyboard: notObserved, | ||
| }, | ||
| // A keyboard window that exposes no keys is not proof that the keyboard is gone. | ||
| { | ||
| name: 'a keyboard window without keys', | ||
| windows: [guestNode('UIRemoteKeyboardWindow', APP_FRAME)], | ||
| truncated: false, | ||
| keyboard: notObserved, | ||
| }, | ||
| { | ||
| name: 'a keyboard window that holds only a placeholder', | ||
| windows: [ | ||
| guestNode('UIRemoteKeyboardWindow', APP_FRAME, [ | ||
| guestNode('_UIRemoteKeyboardPlaceholderView', { X: 0, Y: 509, Width: 390, Height: 335 }), | ||
| ]), | ||
| ], | ||
| truncated: false, | ||
| keyboard: notObserved, | ||
| }, | ||
| // A minimized or hardware keyboard leaves only its assistant bar. | ||
| { | ||
| name: 'an assistant bar without keys', | ||
| windows: [ | ||
| guestNode('UIRemoteKeyboardWindow', APP_FRAME, [ | ||
| guestNode('TUISystemInputAssistantView', ASSISTANT_FRAME), | ||
| ]), | ||
| ], | ||
| truncated: false, | ||
| keyboard: notObserved, | ||
| }, | ||
| ]; |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -607,7 +607,7 @@ agent-device is text 'id="greeting"' "Welcome back" | |||||||||
| - On the web, on-screen text the browser cannot hand an action to (static text, a `status` announcement) is in the tree and readable by `wait text`, `find text`, `get text`, and `text=`, but carries no `@ref` and has no rect: `find ... list` prints it as `(no ref)`, and it is never a tap target. Such text is published on `value`, so `value="X"` can match page text as well as a field holding that text, and a predicate that needs one element then reports `AMBIGUOUS_MATCH`; use `text=` for page text, or add `editable=true` to name the field. | ||||||||||
| - The first read after `scroll`, `swipe`, or `gesture swipe` waits for two captures to agree. When that proves something about the gesture, `is`, `get`, `find`, `wait`, interactions that captured it, and `snapshot` add a warning, and all but `snapshot` report `postGestureOutcome` (`{ "kind", "gesture": { "action", "positionals" } }`) in `data` or `error.details`: | ||||||||||
| - `kind: "unsettled"`: the screen was still changing. A miss is not proof of absence; read again. `is absent` refuses with `observation: "unsettled"`, and `wait absent` keeps polling. | ||||||||||
| - `kind: "no-effect"`: nothing changed. The list may be at its edge or ignore synthesized scrolls; a raw `swipe x1 y1 x2 y2` inside it moves such lists. The soft keyboard appearing or going away counts as a change only when the pre-gesture baseline and a later quiet capture both read the keyboard from their own tree and disagree: the iOS XCTest runner does, a WebDriver (Appium) visibility probe does not. Otherwise only the content is compared. | ||||||||||
| - `kind: "no-effect"`: nothing changed. The list may be at its edge or ignore synthesized scrolls; a raw `swipe x1 y1 x2 y2` inside it moves such lists. The soft keyboard appearing or going away counts as a change only when the pre-gesture baseline and a later quiet capture both read the keyboard from their own tree and disagree: the iOS XCTest runner and the iOS Simulator AX bridge (the default Simulator route) do, a WebDriver (Appium) visibility probe does not. The AX bridge reads the keyboard as gone only from a complete tree that holds no keyboard window. Otherwise only the content is compared. | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The command documentation narrows the bridge’s absence rule too far. Mention Prompt for AI agents
Suggested change
|
||||||||||
|
|
||||||||||
| ## Replay | ||||||||||
|
|
||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: The changeset overstates truncated-tree behavior: a truncated capture can publish
derivedortree-rejected, not onlyunmeasurable. Describe truncation as never proving absence, while preserving the fact established by its captured evidence.Prompt for AI agents