mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(ioscompanion): route text input through a text-editing companion when available
This commit is contained in:
1 parent
08e4bb9e2f
commit
ce6b99dd7d
2 files changed
+113
-2
No files matched your search
@@ -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
|
// 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 {
|
func isConnectionError(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if errors.Is(err, io.EOF) {
|
if errors.Is(err, io.EOF) || errors.Is(err, transport.ErrCompanionUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if statusValue, ok := status.FromError(err); ok {
|
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 {
|
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)
|
usage, ok := pressKeyUsage(key)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("ios companion: unsupported key %q", key)
|
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.
|
// 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
|
// Only Return/Enter has a hardware-keyboard equivalent on the simulator; other
|
||||||
// names (notably "back" and "home") have no HID key and report unsupported.
|
// 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 {
|
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
|
// 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
|
// requires a describe-all, so the fast keyboard path skips that round-trip
|
||||||
// and lets inputText send the key presses directly.
|
// 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 {
|
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)
|
return eraseText(ctx, d.makeRunner(), characterCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -490,3 +490,86 @@ func TestNewChildOutlivesStartup(t *testing.T) {
|
|||||||
t.Fatal("spawn context still alive after Close; child lifetime leaks")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user