diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 36392f0..5ab9e9e 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -226,7 +226,7 @@ func Run(ctx context.Context, options Options) (Summary, error) { applySkipped := false if nextErr == nil { - if err := applyAction(ctx, options.Driver, nextAction, tree, options.IdleTimeout); err != nil { + if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil { if isWDADrop(err) { return summary, fmt.Errorf("step %d: the iOS XCTest runner could not be restarted - re-run the test: %w", stepIndex, err) } @@ -416,6 +416,12 @@ func ensureForeground(ctx context.Context, options Options, logger *slog.Logger, // never hang the run. const foregroundReadyAttempts = 8 +// focusTapSettle is the pause after tapping a field to focus it, before typing. +// Long enough for focus to land, short enough to avoid the ~500ms-1s full +// settle the keyboard's open animation would otherwise cost every InputText +// step on a physical device. +const focusTapSettle = 250 * time.Millisecond + // waitForForeground blocks until the app under test is actually on screen, so // the first observe never captures a leftover screen or a freshly-booted // device's system dialog (e.g. Android's "set a screen lock" prompt). Drivers @@ -507,7 +513,7 @@ func settleForForeground(ctx context.Context, options Options) { cancel() } -func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree, idleTimeout time.Duration) error { +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) @@ -556,13 +562,19 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A } tapped = true } - // The focus tap raises the keyboard. Settle before sending key - // events so the keyboard animation cannot race them into the wrong - // field (or drop them entirely). + // The focus tap raises the keyboard. The tap registers focus + // immediately and the text is injected into the focused view (not typed + // on the visible keyboard), so a brief pause is enough for focus to land + // rather than a full settle, which costs ~500ms-1s per InputText step on + // a physical device while the keyboard animates in. if tapped { - idleCtx, idleCancel := context.WithTimeout(ctx, idleTimeout) - _ = drv.WaitForIdle(idleCtx, idleTimeout) - idleCancel() + timer := time.NewTimer(focusTapSettle) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } } // InputText replaces the field's content: erase what the target // holds before typing. Appending instead lets repeated draws grow diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index f447170..a87fc87 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -662,41 +662,39 @@ func TestApplyAction_InputTextErasesExistingTextBeforeTyping(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { 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. actions := driverMock.Actions() - if len(actions) != 4 { - t.Fatalf("want tap, wait_for_idle, erase, input; got %v", 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[1].Kind != mockdriver.ActionWaitForIdle { - t.Errorf("second action = %v, want wait_for_idle (settle after focus tap)", actions[1].Kind) + 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")) } - if actions[2].Kind != mockdriver.ActionEraseText || actions[2].CharacterCount != len("stale-value") { - t.Errorf("third action = %+v, want erase_text of %d characters", actions[2], len("stale-value")) - } - if actions[3].Kind != mockdriver.ActionInputText || actions[3].Text != "alice" { - t.Errorf("fourth action = %+v, want input_text alice", actions[3]) + if actions[2].Kind != mockdriver.ActionInputText || actions[2].Text != "alice" { + t.Errorf("third action = %+v, want input_text alice", actions[2]) } } -// TestApplyAction_InputTextWithoutTargetSkipsSettle pins that the post-tap -// settle only runs when a focus tap actually happened: with no resolvable -// target there is no keyboard animation to absorb. -func TestApplyAction_InputTextWithoutTargetSkipsSettle(t *testing.T) { +// TestApplyAction_InputTextWithoutTargetSkipsFocusTap pins that with no +// resolvable target there is no focus tap (and so no settle), and InputText +// still runs at the cursor. +func TestApplyAction_InputTextWithoutTargetSkipsFocusTap(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindInputText, X: -1, Y: -1, Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("applyAction: %v", err) } - for _, recorded := range driverMock.Actions() { - if recorded.Kind == mockdriver.ActionWaitForIdle { - t.Errorf("no focus tap happened; settle must be skipped: %v", driverMock.Actions()) - } + actions := driverMock.Actions() + if len(actions) != 1 || actions[0].Kind != mockdriver.ActionInputText { + t.Errorf("no target: want input_text only (no focus tap), got %v", actions) } } @@ -714,7 +712,7 @@ func TestApplyAction_InputTextSkipsEraseForReplacingDriver(t *testing.T) { driverMock.ReplacesText = true action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { t.Fatalf("applyAction: %v", err) } if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") { @@ -735,7 +733,7 @@ func TestApplyAction_InputTextSkipsEraseWhenTargetEmpty(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { t.Fatalf("applyAction: %v", err) } if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") { @@ -749,7 +747,7 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { driverMock.Failures[mockdriver.ActionTapSelector] = errors.New("adb unreachable") action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} - err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond) + err := applyAction(context.Background(), driverMock, action, nil) if err == nil { t.Fatalf("expected focus tap failure to surface, got nil") } @@ -762,7 +760,7 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { driverMock.Failures[mockdriver.ActionTap] = errors.New("tap driver error") action := verifier.Action{Kind: verifier.ActionKindInputText, X: 10, Y: 20, Text: "alice"} - err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond) + err := applyAction(context.Background(), driverMock, action, nil) if err == nil { t.Fatalf("expected focus tap failure to surface, got nil") } @@ -776,7 +774,7 @@ func TestApplyAction_V8InputTextTapsAtCoordinates(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindInputText, X: 50, Y: 100, Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } actions := driverMock.Actions() @@ -795,7 +793,7 @@ func TestApplyAction_V8InputTextAtOriginStillTaps(t *testing.T) { // InputText with (0,0) is a deliberate edge tap, not a sentinel). action := verifier.Action{Kind: verifier.ActionKindInputText, X: 0, Y: 0, Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } if !containsAction(driverMock.Actions(), mockdriver.ActionTap, "") { @@ -807,7 +805,7 @@ func TestApplyAction_DoubleTapDispatchesDoubleTapAtCoordinates(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindDoubleTap, X: 100, Y: 200} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } taps := 0 @@ -825,7 +823,7 @@ func TestApplyAction_DoubleTapDispatchesDoubleTapSelector(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindDoubleTap, On: "id:save"} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } taps := 0 @@ -843,7 +841,7 @@ func TestApplyAction_LongPressDispatchesAtResolvedCoordinates(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindLongPress, X: 120, Y: 240} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } found := false @@ -869,7 +867,7 @@ func TestApplyAction_ScrollWithPrecomputedEndpointsSwipes(t *testing.T) { DurationMillis: 300, } - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } found := false @@ -892,7 +890,7 @@ func TestApplyAction_ScrollDirectionUsesInversion(t *testing.T) { } action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "down", On: "id:list"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { t.Fatalf("apply action: %v", err) } var swipe *mockdriver.Action @@ -921,7 +919,7 @@ func TestApplyAction_ScrollScreenFallback(t *testing.T) { // On unset: container falls back to whole-screen (root) bounds. action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "up"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { t.Fatalf("apply action: %v", err) } var swipe *mockdriver.Action