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.
This commit is contained in:
pj committed 2026-06-06 00:35:13 +05:30
1 parent 6009adac48
commit 29ec13adcd
5 files changed
+61

No files matched your search

+6
View File
@@ -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 {
+10
View File
@@ -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
+10
View File
@@ -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
+10
View File
@@ -512,11 +512,14 @@ 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.
// 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)
case verifier.ActionKindSwipe:
duration := time.Duration(action.DurationMillis) * time.Millisecond
@@ -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.
+25
View File
@@ -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":[]}