diff --git a/internal/runner/runner.go b/internal/runner/runner.go index e30ad04..44d4304 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -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. diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 859497b..1e0fb07 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -822,6 +822,103 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { }) } +// loginFocusOnEmail is the folio login screen as Android reports it once the +// email field has been typed into: email holds focus, password does not. +const loginFocusOnEmail = `{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ + {"attributes":{"resource-id":"LoginEmail","text":"demo@folio.app","bounds":"[94,240,986,372]"},"focused":true,"children":[]}, + {"attributes":{"resource-id":"LoginPassword","bounds":"[94,461,986,593]"},"focused":false,"children":[]} +]}` + +const loginFocusOnPassword = `{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ + {"attributes":{"resource-id":"LoginEmail","text":"demo@folio.app","bounds":"[94,240,986,372]"},"focused":false,"children":[]}, + {"attributes":{"resource-id":"LoginPassword","bounds":"[94,461,986,593]"},"focused":true,"children":[]} +]}` + +// A keyboard overlay window can sit over the field the focus tap aims at, so +// the tap never reaches it and focus stays where it was. Typing then appends to +// the previously focused field: on folio the password ran into the email field +// and the login setup leaf retried forever. +func TestApplyAction_InputTextStopsWhenAnotherFieldHoldsFocus(t *testing.T) { + fastFocusSettle(t) + tree, err := hierarchy.Parse(loginFocusOnEmail) + if err != nil { + t.Fatalf("Parse: %v", err) + } + driverMock := mockdriver.New() + driverMock.HierarchyJSON = loginFocusOnEmail + action := verifier.Action{ + Kind: verifier.ActionKindInputText, + On: "id:LoginPassword", + Text: "ledger123", + } + + _, err = applyAction(context.Background(), driverMock, action, tree) + if err == nil { + t.Fatal("a focus tap that never focused the target must be reported, not typed through") + } + if !strings.Contains(err.Error(), "LoginEmail") { + t.Errorf("error must name the field holding focus, got: %v", err) + } + if containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") { + t.Errorf("password text must not be typed into the focused email field: %v", driverMock.Actions()) + } +} + +func TestApplyAction_InputTextTypesWhenTargetTakesFocus(t *testing.T) { + fastFocusSettle(t) + tree, err := hierarchy.Parse(loginFocusOnEmail) + if err != nil { + t.Fatalf("Parse: %v", err) + } + driverMock := mockdriver.New() + driverMock.HierarchyJSON = loginFocusOnPassword + action := verifier.Action{ + Kind: verifier.ActionKindInputText, + On: "id:LoginPassword", + Text: "ledger123", + } + + mustDispatch(t, driverMock, action, tree) + if !typedText(driverMock.Actions(), "ledger123") { + t.Errorf("expected InputText once the target holds focus, got %v", driverMock.Actions()) + } +} + +// Platforms whose hierarchy omits focus entirely (iOS) have nothing to compare, +// so they must not pay a hierarchy read per InputText. +func TestApplyAction_InputTextSkipsFocusCheckWhenHierarchyOmitsFocus(t *testing.T) { + fastFocusSettle(t) + tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ + {"attributes":{"resource-id":"LoginPassword","bounds":"[94,461,986,593]"},"children":[]} + ]}`) + if err != nil { + t.Fatalf("Parse: %v", err) + } + driverMock := mockdriver.New() + action := verifier.Action{ + Kind: verifier.ActionKindInputText, + On: "id:LoginPassword", + Text: "ledger123", + } + + mustDispatch(t, driverMock, action, tree) + if containsAction(driverMock.Actions(), mockdriver.ActionHierarchy, "") { + t.Errorf("no focus to compare: expected no hierarchy read, got %v", driverMock.Actions()) + } + if !typedText(driverMock.Actions(), "ledger123") { + t.Errorf("expected InputText, got %v", driverMock.Actions()) + } +} + +func typedText(actions []mockdriver.Action, text string) bool { + for _, action := range actions { + if action.Kind == mockdriver.ActionInputText && action.Text == text { + return true + } + } + return false +} + func TestApplyAction_V8InputTextTapsAtCoordinates(t *testing.T) { fastFocusSettle(t) driverMock := mockdriver.New()