From 29ec13adcd7b4d6af75d3eac470bb871a9518bd7 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 00:35:13 +0530 Subject: [PATCH] fix(driver): skip pre-erase for replace-on-input drivers The web driver's InputText already replaces content via select-all, so the runner's unconditional EraseText was a redundant round-trip on every InputText. A new optional TextReplacer capability lets a driver assert replace semantics; the runner skips the erase when asserted. --- internal/driver/chrome/driver.go | 6 ++++++ internal/driver/driver.go | 10 ++++++++++ internal/driver/mock/mock.go | 10 ++++++++++ internal/runner/runner.go | 16 +++++++++++++--- internal/runner/runner_test.go | 25 +++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index df8aad1..829a8a2 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -193,6 +193,12 @@ func (d *Driver) InputText(_ context.Context, text string) error { ) } +// ReplacesTextOnInput reports that InputText replaces existing content via +// select-all, so the runner skips its pre-erase. +func (d *Driver) ReplacesTextOnInput() bool { + return true +} + // EraseText clears the focused field. InputText above already replaces via // select-all, so the character count is not needed to bound the deletion. func (d *Driver) EraseText(_ context.Context, _ int) error { diff --git a/internal/driver/driver.go b/internal/driver/driver.go index e4757d2..649d673 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -60,6 +60,16 @@ type ForegroundChecker interface { ForegroundApp(ctx context.Context) (string, error) } +// TextReplacer is the optional capability for drivers whose InputText already +// replaces the field's content instead of appending to it. The runner must +// skip its pre-erase for such drivers: the erase would be a redundant +// round-trip on every InputText. +type TextReplacer interface { + // ReplacesTextOnInput reports whether InputText replaces existing + // content, making the runner's pre-erase unnecessary. + ReplacesTextOnInput() bool +} + // FocusedWindowChecker is the optional capability for reporting which app owns // the focused (on-screen) window. The startup gate prefers it over // ForegroundChecker: the resumed-activity signal flips to a freshly launched diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index f60c323..b0e8f27 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -63,6 +63,10 @@ type Driver struct { MetricsData driver.Metrics Failures map[ActionKind]error + // ReplacesText makes the mock assert the TextReplacer capability, so + // tests cover both the erase-before-type and replace-on-input paths. + ReplacesText bool + // ForegroundResults is consumed one entry per ForegroundApp call (the // last entry repeats). Empty yields "", which disables the runner's // app-scope guard so tests that don't care are unaffected. @@ -212,6 +216,12 @@ func (d *Driver) InputText(ctx context.Context, text string) error { return nil } +func (d *Driver) ReplacesTextOnInput() bool { + d.mutex.Lock() + defer d.mutex.Unlock() + return d.ReplacesText +} + func (d *Driver) EraseText(ctx context.Context, characterCount int) error { if err := d.failure(ActionEraseText); err != nil { return err diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 76cb55d..ee92925 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -512,9 +512,12 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A // 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 + // Drivers whose InputText already replaces skip the erase entirely. + if !inputReplacesText(drv) { + if count := existingTextLength(action, tree); count > 0 { + if err := drv.EraseText(ctx, count); err != nil { + return err + } } } return drv.InputText(ctx, action.Text) @@ -567,6 +570,13 @@ func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time) return result } +// inputReplacesText reports whether the driver's InputText replaces existing +// content, making the runner's pre-erase redundant. +func inputReplacesText(drv driver.DeviceDriver) bool { + replacer, ok := drv.(driver.TextReplacer) + return ok && replacer.ReplacesTextOnInput() +} + // 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 0feb85b..6725fad 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -628,6 +628,31 @@ func TestApplyAction_InputTextWithoutTargetSkipsSettle(t *testing.T) { } } +// TestApplyAction_InputTextSkipsEraseForReplacingDriver pins that a driver +// asserting the TextReplacer capability never pays the pre-erase round-trip: +// its InputText already replaces the field's content. +func TestApplyAction_InputTextSkipsEraseForReplacingDriver(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() + 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 { + t.Fatalf("applyAction: %v", err) + } + if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") { + t.Errorf("replacing driver must not be asked to erase: %v", driverMock.Actions()) + } + if !containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") { + t.Errorf("expected InputText, got %v", driverMock.Actions()) + } +} + 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":[]}