diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 8b4b038..af070bf 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -497,6 +497,15 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A return err } } + // InputText replaces the field's content: erase what the target + // holds before typing. Appending instead lets repeated draws grow + // the field without bound (e.g. into a max-length validation error + // the fuzzer can never escape) and makes retried typing land twice. + if count := existingTextLength(action, tree); count > 0 { + if err := drv.EraseText(ctx, count); err != nil { + return err + } + } return drv.InputText(ctx, action.Text) case verifier.ActionKindSwipe: duration := time.Duration(action.DurationMillis) * time.Millisecond @@ -547,6 +556,20 @@ func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time) return result } +// 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. +func existingTextLength(action verifier.Action, tree *hierarchy.Tree) int { + if action.On == "" || tree == nil { + return 0 + } + element := tree.Find(action.On) + if element == nil { + return 0 + } + return len([]rune(element.Text)) +} + func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) { // When On is empty, X/Y are authoritative (web V8 path emits coordinates // directly from getBoundingClientRect; the runtime nullifies unresolved diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 7c37521..1fe2394 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -370,6 +370,52 @@ func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) { } } +func TestApplyAction_InputTextErasesExistingTextBeforeTyping(t *testing.T) { + tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ + {"attributes":{"resource-id":"username","text":"stale-value","bounds":"[10,10,500,100]"},"children":[]} + ]}`) + if err != nil { + t.Fatalf("Parse: %v", err) + } + driverMock := mockdriver.New() + action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} + + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { + t.Fatalf("applyAction: %v", err) + } + 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[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.ActionInputText || actions[2].Text != "alice" { + t.Errorf("third action = %+v, want input_text alice", actions[2]) + } +} + +func TestApplyAction_InputTextSkipsEraseWhenTargetEmpty(t *testing.T) { + tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ + {"attributes":{"resource-id":"username","bounds":"[10,10,500,100]"},"children":[]} + ]}`) + if err != nil { + t.Fatalf("Parse: %v", err) + } + driverMock := mockdriver.New() + action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} + + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { + t.Fatalf("applyAction: %v", err) + } + if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") { + t.Errorf("empty field must not be erased: %v", driverMock.Actions()) + } +} + func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { t.Run("selector focus tap fails", func(t *testing.T) { driverMock := mockdriver.New()