From bf2da973dec0c3166cf3997f3b364488f8b950a6 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 14:02:01 +0530 Subject: [PATCH] fix(runner): an ambiguous name loses to the coordinates it was built from Attribute values match by substring, so a selector that named one element where the candidate was built can name several in the tree it resolves against, and the lookup sent every one of them to the first match. The host gates blank an ambiguous tag at enumeration time; this closes the gap between that moment and the action. A bare-string target carries no coordinates, so the first match stays the answer there rather than dropping an authored action. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX --- internal/runner/runner.go | 11 ++++++-- internal/runner/runner_test.go | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index d429115..97872ca 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -903,8 +903,15 @@ func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, return 0, 0, false } if tree != nil { - if element := tree.Find(action.On); element != nil { - x, y := element.Bounds.Center() + // An ambiguous selector names several elements while the action's own + // coordinates name one, so the coordinates win. Attribute values match + // by substring, so a selector unique where the candidate was built can + // be ambiguous in the tree it resolves against. A bare-string target + // carries no coordinates, and there the name is all there is. + matches := tree.FindAll(action.On) + hasCoordinates := action.X > 0 && action.Y > 0 + if len(matches) > 0 && (len(matches) == 1 || !hasCoordinates) { + x, y := matches[0].Bounds.Center() if x > 0 && y > 0 { return x, y, true } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index ecbb241..08b2fd1 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1037,6 +1037,53 @@ func TestApplyAction_V8InputTextAtOriginStillTaps(t *testing.T) { } } +// Attribute values match by substring, so "data-testid:card" answers to +// "card-1" and "card-10" alike. A candidate built where the selector named one +// element can execute where it names several, and the tree lookup would send +// every one of them to the first match. +func TestApplyAction_AmbiguousSelectorTapsTheActionsOwnCoordinates(t *testing.T) { + tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ + {"attributes":{"data-testid":"card-1","bounds":"[0,100,200,200]"},"children":[]}, + {"attributes":{"data-testid":"card-10","bounds":"[0,300,200,400]"},"children":[]} + ]}`) + if err != nil { + t.Fatalf("Parse: %v", err) + } + driverMock := mockdriver.New() + action := verifier.Action{Kind: verifier.ActionKindTap, On: "data-testid:card-1", X: 100, Y: 350} + + mustDispatch(t, driverMock, action, tree) + for _, dispatched := range driverMock.Actions() { + if dispatched.Kind == mockdriver.ActionTap && dispatched.X == 100 && dispatched.Y == 350 { + return + } + } + t.Errorf("tap reached the driver at %v, want the action's own (100,350)", driverMock.Actions()) +} + +// A bare-string target carries no coordinates of its own, so an ambiguous name +// is all there is to act on and the first match stays the answer. Refusing it +// would drop an authored action. +func TestApplyAction_AmbiguousSelectorWithoutCoordinatesTapsTheFirstMatch(t *testing.T) { + tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ + {"attributes":{"data-testid":"card-1","bounds":"[0,100,200,200]"},"children":[]}, + {"attributes":{"data-testid":"card-10","bounds":"[0,300,200,400]"},"children":[]} + ]}`) + if err != nil { + t.Fatalf("Parse: %v", err) + } + driverMock := mockdriver.New() + action := verifier.Action{Kind: verifier.ActionKindTap, On: "data-testid:card-1"} + + mustDispatch(t, driverMock, action, tree) + for _, dispatched := range driverMock.Actions() { + if dispatched.Kind == mockdriver.ActionTap && dispatched.X == 100 && dispatched.Y == 150 { + return + } + } + t.Errorf("tap reached the driver at %v, want the first match's centre (100,150)", driverMock.Actions()) +} + func TestApplyAction_DoubleTapDispatchesDoubleTapAtCoordinates(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindDoubleTap, X: 100, Y: 200}