From 39ccd5c7bf711735c692e78e458fc3a40973aef0 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 18 Jun 2026 11:50:43 -0400 Subject: [PATCH] fix(runner): tap by selector to survive layout shift after observe --- internal/runner/runner.go | 40 ++++++++++++++++++++-------------- internal/runner/runner_test.go | 7 +++--- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index e6d875d..23158be 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -562,23 +562,27 @@ func settleForForeground(ctx context.Context, options Options) { func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error { switch action.Kind { case verifier.ActionKindTap: - x, y, ok := resolveCoordinates(action, tree) - if !ok { - if action.On == "" { - return nil - } + // A selector resolves live on-device at tap time, so it lands correctly + // even when the layout shifted between observe and apply (e.g. the soft + // keyboard finished animating out, moving every control down). Tapping + // the observe-time coordinates would miss the moved control. Coordinates + // are the fallback for selector-less actions (the web/V8 path only emits + // coordinates). + if action.On != "" { return drv.TapSelector(ctx, action.On) } - return drv.Tap(ctx, x, y) + if x, y, ok := resolveCoordinates(action, tree); ok { + return drv.Tap(ctx, x, y) + } + return nil case verifier.ActionKindDoubleTap: - x, y, ok := resolveCoordinates(action, tree) - if !ok { - if action.On == "" { - return nil - } + if action.On != "" { return drv.DoubleTapSelector(ctx, action.On) } - return drv.DoubleTap(ctx, x, y) + if x, y, ok := resolveCoordinates(action, tree); ok { + return drv.DoubleTap(ctx, x, y) + } + return nil case verifier.ActionKindLongPress: x, y, ok := resolveCoordinates(action, tree) if !ok { @@ -596,14 +600,18 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A } return drv.Swipe(ctx, fromX, fromY, toX, toY, duration) case verifier.ActionKindInputText: + // Focus the field by selector when one is present, for the same + // layout-shift reason as Tap: InputText is the action that raises the + // keyboard, so its follow-on focus tap is the most likely to land on a + // stale position. Coordinates focus the selector-less web/V8 path. tapped := false - if x, y, ok := resolveCoordinates(action, tree); ok { - if err := drv.Tap(ctx, x, y); err != nil { + if action.On != "" { + if err := drv.TapSelector(ctx, action.On); err != nil { return err } tapped = true - } else if action.On != "" { - if err := drv.TapSelector(ctx, action.On); err != nil { + } else if x, y, ok := resolveCoordinates(action, tree); ok { + if err := drv.Tap(ctx, x, y); err != nil { return err } tapped = true diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index db7c7fa..15f7618 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -673,13 +673,14 @@ func TestApplyAction_InputTextErasesExistingTextBeforeTyping(t *testing.T) { t.Fatalf("applyAction: %v", err) } // The post-tap settle is now a brief internal sleep, not a WaitForIdle RPC, - // so the recorded driver actions are tap, erase, input. + // so the recorded driver actions are focus-tap, erase, input. The focus tap + // goes by selector (live on-device resolution) since the action carries one. actions := driverMock.Actions() if len(actions) != 3 { t.Fatalf("want tap, erase, input; got %v", actions) } - if actions[0].Kind != mockdriver.ActionTap { - t.Errorf("first action = %v, want tap", actions[0].Kind) + if actions[0].Kind != mockdriver.ActionTapSelector || actions[0].Selector != "id:username" { + t.Errorf("first action = %+v, want tap_selector id:username", actions[0]) } if actions[1].Kind != mockdriver.ActionEraseText || actions[1].CharacterCount != len("stale-value") { t.Errorf("second action = %+v, want erase_text of %d characters", actions[1], len("stale-value"))