From ce6b99dd7d135dc29d1ea378077e5b5214d24c0c Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 7 Jun 2026 15:42:32 +0530 Subject: [PATCH] feat(ioscompanion): route text input through a text-editing companion when available --- internal/driver/ioscompanion/driver.go | 32 +++++++- internal/driver/ioscompanion/driver_test.go | 83 +++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/internal/driver/ioscompanion/driver.go b/internal/driver/ioscompanion/driver.go index 9ea9b82..67bd8db 100644 --- a/internal/driver/ioscompanion/driver.go +++ b/internal/driver/ioscompanion/driver.go @@ -260,12 +260,13 @@ func (d *Driver) withRecovery(ctx context.Context, call func() error) error { } // isConnectionError reports whether err is a dropped-connection signal that a -// restart can recover from: a gRPC Unavailable status or an EOF. +// restart can recover from: the transport's unavailable sentinel, a gRPC +// Unavailable status, or an EOF. func isConnectionError(err error) bool { if err == nil { return false } - if errors.Is(err, io.EOF) { + if errors.Is(err, io.EOF) || errors.Is(err, transport.ErrCompanionUnavailable) { return true } if statusValue, ok := status.FromError(err); ok { @@ -433,6 +434,11 @@ func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration } func (d *Driver) PressKey(ctx context.Context, key string) error { + if d.textEditor() != nil { + return d.withRecovery(ctx, func() error { + return d.textEditor().PressKey(ctx, key) + }) + } usage, ok := pressKeyUsage(key) if !ok { return fmt.Errorf("ios companion: unsupported key %q", key) @@ -442,6 +448,16 @@ func (d *Driver) PressKey(ctx context.Context, key string) error { }) } +// textEditor returns the companion's native text-editing capability, or nil +// when the transport does not implement it. Resolved per call because a +// restart replaces d.companion. +func (d *Driver) textEditor() transport.TextEditor { + if editor, ok := d.companion.(transport.TextEditor); ok { + return editor + } + return nil +} + // pressKeyUsage maps the logical key names mobile runs emit to a HID usage. // Only Return/Enter has a hardware-keyboard equivalent on the simulator; other // names (notably "back" and "home") have no HID key and report unsupported. @@ -490,6 +506,13 @@ func (d *Driver) resolveSelectorCenter(ctx context.Context, selector string) (in } func (d *Driver) InputText(ctx context.Context, text string) error { + // A text-editing companion replaces the field's content natively, which + // covers unicode without the pasteboard and its permission dialog. + if d.textEditor() != nil { + return d.withRecovery(ctx, func() error { + return d.textEditor().InputText(ctx, text) + }) + } // The field target is only needed for the pasteboard path. Resolving it // requires a describe-all, so the fast keyboard path skips that round-trip // and lets inputText send the key presses directly. @@ -538,6 +561,11 @@ func (d *Driver) resolveInputField(ctx context.Context) fieldTarget { } func (d *Driver) EraseText(ctx context.Context, characterCount int) error { + if d.textEditor() != nil { + return d.withRecovery(ctx, func() error { + return d.textEditor().EraseText(ctx, characterCount) + }) + } return eraseText(ctx, d.makeRunner(), characterCount) } diff --git a/internal/driver/ioscompanion/driver_test.go b/internal/driver/ioscompanion/driver_test.go index 8c083ed..05bd19e 100644 --- a/internal/driver/ioscompanion/driver_test.go +++ b/internal/driver/ioscompanion/driver_test.go @@ -490,3 +490,86 @@ func TestNewChildOutlivesStartup(t *testing.T) { t.Fatal("spawn context still alive after Close; child lifetime leaks") } } + +// fakeTextEditingCompanion extends fakeCompanion with the optional TextEditor +// capability so routing through the native text path is testable. +type fakeTextEditingCompanion struct { + fakeCompanion + + inputTexts []string + eraseCounts []int + pressedKeys []string +} + +func (f *fakeTextEditingCompanion) InputText(_ context.Context, text string) error { + f.record("inputtext") + f.inputTexts = append(f.inputTexts, text) + return nil +} + +func (f *fakeTextEditingCompanion) EraseText(_ context.Context, characterCount int) error { + f.record("erasetext") + f.eraseCounts = append(f.eraseCounts, characterCount) + return nil +} + +func (f *fakeTextEditingCompanion) PressKey(_ context.Context, key string) error { + f.record("presskey") + f.pressedKeys = append(f.pressedKeys, key) + return nil +} + +var _ transport.TextEditor = (*fakeTextEditingCompanion)(nil) + +func TestInputTextRoutesThroughTextEditor(t *testing.T) { + companion := &fakeTextEditingCompanion{} + d := newTestDriver(companion) + if err := d.InputText(context.Background(), "héllo 🌟"); err != nil { + t.Fatal(err) + } + if len(companion.inputTexts) != 1 || companion.inputTexts[0] != "héllo 🌟" { + t.Fatalf("inputTexts = %v, want the typed text once", companion.inputTexts) + } + for _, call := range companion.calls { + if call == "hid" { + t.Fatal("text editor path must not compose HID streams") + } + } +} + +func TestEraseTextRoutesThroughTextEditor(t *testing.T) { + companion := &fakeTextEditingCompanion{} + d := newTestDriver(companion) + if err := d.EraseText(context.Background(), 7); err != nil { + t.Fatal(err) + } + if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 7 { + t.Fatalf("eraseCounts = %v, want [7]", companion.eraseCounts) + } +} + +func TestPressKeyRoutesThroughTextEditor(t *testing.T) { + companion := &fakeTextEditingCompanion{} + d := newTestDriver(companion) + if err := d.PressKey(context.Background(), "enter"); err != nil { + t.Fatal(err) + } + if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" { + t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys) + } + for _, call := range companion.calls { + if call == "hid" { + t.Fatal("text editor path must not compose HID streams") + } + } +} + +func TestIsConnectionErrorRecognizesUnavailableSentinel(t *testing.T) { + wrapped := errors.Join(errors.New("dial tcp: connection refused"), transport.ErrCompanionUnavailable) + if !isConnectionError(wrapped) { + t.Fatal("wrapped ErrCompanionUnavailable must count as a connection error") + } + if isConnectionError(errors.New("ordinary failure")) { + t.Fatal("ordinary errors must not count as connection errors") + } +}