fix(runner): confirm focus moved before typing

InputText tapped its target, slept, then typed. Android and web both inject
into whatever holds focus, so a tap that missed sent the whole string somewhere
else and nothing reported it. On an emulator with a floating keyboard panel
parked over the password field, the tap pressed the keyboard's emoji key and
every step appended the password to the email instead, forever, because the
setup leaf is guarded on the password being empty.

The hierarchy is re-read after the tap and the target, or something in its
subtree, must hold focus. Platforms whose hierarchy carries no focused
attribute skip the read, so they pay nothing.

Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
pj committed 2026-08-13 23:00:39 +05:30
1 parent 6fd3fef9fa
commit 9a2bbbf769
2 files changed
+188

No files matched your search

+91
View File
@@ -653,6 +653,9 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A
return "", ctx.Err()
case <-timer.C:
}
if err := confirmFocus(ctx, drv, action.On, tree); err != nil {
return "", err
}
}
// InputText replaces the field's content: erase what the target
// holds before typing. Appending instead lets repeated draws grow
@@ -724,6 +727,94 @@ func inputReplacesText(drv driver.DeviceDriver) bool {
return ok && replacer.ReplacesTextOnInput()
}
// confirmFocus fails the action when the device reports focus on an element
// other than the one the focus tap aimed at. Typing is a blind write to
// whatever holds focus, so a tap the target never received (a keyboard overlay
// window covering it, a target that cannot take focus) would stream the
// characters into a different field, corrupting it and every property that
// reads it. Hierarchies that carry no focus at all (iOS) leave nothing to
// compare against, so those platforms are not charged the extra read.
func confirmFocus(
ctx context.Context,
drv driver.DeviceDriver,
selector string,
tree *hierarchy.Tree,
) error {
if selector == "" || !reportsFocus(tree) {
return nil
}
dump, err := drv.Hierarchy(ctx)
if err != nil {
return fmt.Errorf("focus check for %s: %w", selector, err)
}
current, err := hierarchy.Parse(dump)
if err != nil {
return fmt.Errorf("focus check for %s: %w", selector, err)
}
focused := focusedElement(current)
if focused == nil {
return nil
}
if target := current.FindNode(selector); target != nil && holdsFocus(target) {
return nil
}
return fmt.Errorf(
"focus tap on %s did not focus it: %s holds focus, so the text would land there",
selector, elementName(focused),
)
}
// reportsFocus reports whether the platform describes focus at all, which is
// what makes a post-tap focus check meaningful.
func reportsFocus(tree *hierarchy.Tree) bool {
if tree == nil {
return false
}
for _, element := range tree.Elements {
if _, ok := element.Attributes["focused"]; ok {
return true
}
}
return false
}
func focusedElement(tree *hierarchy.Tree) *hierarchy.Element {
for _, element := range tree.Elements {
if element.Focused {
return element
}
}
return nil
}
// holdsFocus accepts focus anywhere in the target's subtree: a selector often
// names the field wrapper while the platform reports focus on the inner
// editable node.
func holdsFocus(node *hierarchy.Node) bool {
if node.Focused {
return true
}
for _, child := range node.Children {
if holdsFocus(child) {
return true
}
}
return false
}
func elementName(element *hierarchy.Element) string {
switch {
case element.ResourceID != "":
return element.ResourceID
case element.Description != "":
return element.Description
case element.Class != "":
return element.Class
default:
return "an unnamed element"
}
}
// existingTextLength returns the character count of the InputText target's
// current text, so the runner can erase it before typing. Zero when the
// target cannot be resolved or holds no text.