From da3cc5f68cb8aff7a86d5ffa8ac12e21d5bcb69c Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 17:41:33 +0530 Subject: [PATCH] feat(driver): add escape to the pressKey surface escape is a key a spec has real use for and no platform could send it. android maps it to KEYCODE_ESCAPE, the ios companion to HID usage 41 and the in-simulator runner to XCUIKeyboardKey.escape, and the Key union accepts it so it can be written at all. --- companion/Sources/TextInput.swift | 6 ++- internal/driver/ioscompanion/driver.go | 7 ++- internal/driver/ioscompanion/keymap.go | 1 + internal/driver/ioscompanion/presskey_test.go | 47 +++++++++++++++++++ .../driver/ioscompanion/transport/runner.go | 8 +++- .../ioscompanion/transport/transport.go | 2 +- pkg/spec/src/types.ts | 1 + pkg/spec/test/api.test.ts | 21 +++++++++ proto/driverpb/driver.pb.go | 4 +- proto/driverpb/driver.proto | 4 +- .../dev/sanderling/sidecar/DriverBackend.kt | 2 + .../dev/sanderling/sidecar/InputTextTest.kt | 13 +++++ 12 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 internal/driver/ioscompanion/presskey_test.go diff --git a/companion/Sources/TextInput.swift b/companion/Sources/TextInput.swift index 2641fae..8e04205 100644 --- a/companion/Sources/TextInput.swift +++ b/companion/Sources/TextInput.swift @@ -46,12 +46,14 @@ enum TextInput { try typeOnFocus(deletes, bundleIdentifier: bundleIdentifier) } - // pressKey types a single logical key into the focused field. Only return is - // supported, matching the simulator companion's key surface. + // pressKey types a single logical key into the focused field, matching the + // simulator companion's key surface. static func pressKey(key: String, bundleIdentifier: String) throws { switch key { case "return", "enter", "Return", "Enter": try typeOnFocus(XCUIKeyboardKey.return.rawValue, bundleIdentifier: bundleIdentifier) + case "escape", "Escape": + try typeOnFocus(XCUIKeyboardKey.escape.rawValue, bundleIdentifier: bundleIdentifier) default: throw TextInputError.typingFailed("unsupported key \(key)") } diff --git a/internal/driver/ioscompanion/driver.go b/internal/driver/ioscompanion/driver.go index 78a2ada..a295192 100644 --- a/internal/driver/ioscompanion/driver.go +++ b/internal/driver/ioscompanion/driver.go @@ -664,12 +664,15 @@ func (d *Driver) runnerTyper() transport.TextTyper { } // 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. +// Return/Enter and Escape are the hardware-keyboard keys the simulator has; +// other names (notably "back" and "home") have no HID key and report +// unsupported. func pressKeyUsage(key string) (uint32, bool) { switch key { case "enter", "return", "Enter", "Return": return usageReturn, true + case "escape", "Escape": + return usageEscape, true default: return 0, false } diff --git a/internal/driver/ioscompanion/keymap.go b/internal/driver/ioscompanion/keymap.go index 8850e68..9bf4ad5 100644 --- a/internal/driver/ioscompanion/keymap.go +++ b/internal/driver/ioscompanion/keymap.go @@ -9,6 +9,7 @@ const ( usage1 = 30 usage0 = 39 usageReturn = 40 + usageEscape = 41 usageTab = 43 usageSpace = 44 usageBackspace = 42 diff --git a/internal/driver/ioscompanion/presskey_test.go b/internal/driver/ioscompanion/presskey_test.go new file mode 100644 index 0000000..87bee4b --- /dev/null +++ b/internal/driver/ioscompanion/presskey_test.go @@ -0,0 +1,47 @@ +package ioscompanion + +import ( + "context" + "testing" + + "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport" +) + +// keyRecordingCompanion keeps the HID events a press produced, so the assertion +// is over what reached the transport rather than over the lookup that built it. +type keyRecordingCompanion struct { + fakeCompanion + events []transport.HIDEvent +} + +func (c *keyRecordingCompanion) SendHID( + _ context.Context, + events ...transport.HIDEvent, +) error { + c.events = append(c.events, events...) + return nil +} + +// docs/manual/spec-language.md documents escape, and the simulator's HID stream +// carries it: usage 41 is the keyboard escape. Without it a spec clause over +// escape reports unsupported on iOS while the same clause runs on web. +func TestPressKeyEscapeReachesTheHIDStream(t *testing.T) { + companion := &keyRecordingCompanion{} + d := newTestDriver(companion) + + if err := d.PressKey(context.Background(), "escape"); err != nil { + t.Fatalf("PressKey escape: %v", err) + } + + // 41 is the USB HID keyboard escape usage, stated here rather than read + // from the production table so a wrong table entry cannot agree with itself. + want := []transport.HIDEvent{transport.KeyDown(41), transport.KeyUp(41)} + if len(companion.events) != len(want) { + t.Fatalf("sent %v, want %v", companion.events, want) + } + for index, event := range companion.events { + if event != want[index] { + t.Fatalf("sent %v, want %v", companion.events, want) + } + } +} diff --git a/internal/driver/ioscompanion/transport/runner.go b/internal/driver/ioscompanion/transport/runner.go index af12f81..bbd03d1 100644 --- a/internal/driver/ioscompanion/transport/runner.go +++ b/internal/driver/ioscompanion/transport/runner.go @@ -336,7 +336,13 @@ func (c *runnerCompanion) PressKey(ctx context.Context, key string) error { case "enter", "return", "Enter", "Return": _, err := c.call(ctx, "pressKey", map[string]any{"key": "return"}) return err + case "escape", "Escape": + _, err := c.call(ctx, "pressKey", map[string]any{"key": "escape"}) + return err default: - return fmt.Errorf("runner companion cannot press key %q; only return is supported", key) + return fmt.Errorf( + "runner companion cannot press key %q; only return and escape are supported", + key, + ) } } diff --git a/internal/driver/ioscompanion/transport/transport.go b/internal/driver/ioscompanion/transport/transport.go index 7412ebc..c80b736 100644 --- a/internal/driver/ioscompanion/transport/transport.go +++ b/internal/driver/ioscompanion/transport/transport.go @@ -58,7 +58,7 @@ type TextEditor interface { // EraseText deletes characterCount characters from the focused field. EraseText(ctx context.Context, characterCount int) error - // PressKey presses the named logical key (currently only return/enter). + // PressKey presses the named logical key (return/enter and escape). PressKey(ctx context.Context, key string) error } diff --git a/pkg/spec/src/types.ts b/pkg/spec/src/types.ts index 32677d0..d09c021 100644 --- a/pkg/spec/src/types.ts +++ b/pkg/spec/src/types.ts @@ -184,6 +184,7 @@ export type Key = | "home" | "enter" | "tab" + | "escape" | "up" | "down" | "left" diff --git a/pkg/spec/test/api.test.ts b/pkg/spec/test/api.test.ts index ce9590b..e0239ad 100644 --- a/pkg/spec/test/api.test.ts +++ b/pkg/spec/test/api.test.ts @@ -38,6 +38,7 @@ import type { EventuallyFormula, Extracted, Formula, + Key, State, SanderlingRuntime, } from "../src/types.ts"; @@ -273,6 +274,26 @@ test("PressKey returns a PressKeyAction descriptor", () => { assert.deepEqual(PressKey({ key: "back" }), { kind: "PressKey", key: "back" }); }); +// Every key docs/manual/spec-language.md documents has to be authorable. A key +// the type rejects cannot appear in a spec at all, so the requirement it stands +// for ("escape discards the edit in progress") is never actuated anywhere. +test("PressKey accepts every documented key", () => { + const keys: Key[] = [ + "back", + "home", + "enter", + "tab", + "escape", + "up", + "down", + "left", + "right", + ]; + for (const key of keys) { + assert.deepEqual(PressKey({ key }), { kind: "PressKey", key }); + } +}); + test("Wait returns a WaitAction descriptor", () => { assert.deepEqual(Wait({ durationMillis: 500 }), { kind: "Wait", durationMillis: 500 }); }); diff --git a/proto/driverpb/driver.pb.go b/proto/driverpb/driver.pb.go index 1ac1ceb..b8b2b83 100644 --- a/proto/driverpb/driver.pb.go +++ b/proto/driverpb/driver.pb.go @@ -623,8 +623,8 @@ func (x *SwipeRequest) GetDurationMillis() int64 { type PressKeyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Logical key name. Supported: "back", "home", "enter", "tab", "up", - // "down", "left", "right". + // Logical key name. Supported: "back", "home", "enter", "tab", "escape", + // "up", "down", "left", "right". Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/proto/driverpb/driver.proto b/proto/driverpb/driver.proto index 217b9ef..4b4663c 100644 --- a/proto/driverpb/driver.proto +++ b/proto/driverpb/driver.proto @@ -95,8 +95,8 @@ message SwipeRequest { } message PressKeyRequest { - // Logical key name. Supported: "back", "home", "enter", "tab", "up", - // "down", "left", "right". + // Logical key name. Supported: "back", "home", "enter", "tab", "escape", + // "up", "down", "left", "right". string key = 1; } diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index daedba2..a496762 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -513,6 +513,7 @@ class StubDriverBackend( "home" to "KEYCODE_HOME", "enter" to "KEYCODE_ENTER", "tab" to "KEYCODE_TAB", + "escape" to "KEYCODE_ESCAPE", "up" to "KEYCODE_DPAD_UP", "down" to "KEYCODE_DPAD_DOWN", "left" to "KEYCODE_DPAD_LEFT", @@ -1207,6 +1208,7 @@ private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? = "KEYCODE_HOME" -> maestro.KeyCode.HOME "KEYCODE_ENTER" -> maestro.KeyCode.ENTER "KEYCODE_TAB" -> maestro.KeyCode.TAB + "KEYCODE_ESCAPE" -> maestro.KeyCode.ESCAPE "KEYCODE_DPAD_UP" -> maestro.KeyCode.REMOTE_UP "KEYCODE_DPAD_DOWN" -> maestro.KeyCode.REMOTE_DOWN "KEYCODE_DPAD_LEFT" -> maestro.KeyCode.REMOTE_LEFT diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt index c466d00..41813c8 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt @@ -212,4 +212,17 @@ class InputTextTest { assertEquals(maestro.KeyCode.ENTER, maestroKeyFor("enter")) assertFailsWith { maestroKeyFor("zorp") } } + + // escape is a documented key. Missing from the table it throws on the adb + // backend and, when a table entry has no device-driver equivalent, the + // device backend used to drop the press with no error at all. + @Test fun escapeReachesBothAndroidBackends() { + val commands = mutableListOf>() + StubDriverBackend("android") { commands.add(it) }.pressKey("escape") + assertEquals( + listOf(listOf("shell", "input", "keyevent", "KEYCODE_ESCAPE")), + commands, + ) + assertEquals(maestro.KeyCode.ESCAPE, maestroKeyFor("escape")) + } }