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.
This commit is contained in:
pj committed 2026-08-16 17:41:33 +05:30
1 parent 4b7a3878ac
commit da3cc5f68c
12 files changed
+106 -10

No files matched your search

+4 -2
View File
@@ -46,12 +46,14 @@ enum TextInput {
try typeOnFocus(deletes, bundleIdentifier: bundleIdentifier) try typeOnFocus(deletes, bundleIdentifier: bundleIdentifier)
} }
// pressKey types a single logical key into the focused field. Only return is // pressKey types a single logical key into the focused field, matching the
// supported, matching the simulator companion's key surface. // simulator companion's key surface.
static func pressKey(key: String, bundleIdentifier: String) throws { static func pressKey(key: String, bundleIdentifier: String) throws {
switch key { switch key {
case "return", "enter", "Return", "Enter": case "return", "enter", "Return", "Enter":
try typeOnFocus(XCUIKeyboardKey.return.rawValue, bundleIdentifier: bundleIdentifier) try typeOnFocus(XCUIKeyboardKey.return.rawValue, bundleIdentifier: bundleIdentifier)
case "escape", "Escape":
try typeOnFocus(XCUIKeyboardKey.escape.rawValue, bundleIdentifier: bundleIdentifier)
default: default:
throw TextInputError.typingFailed("unsupported key \(key)") throw TextInputError.typingFailed("unsupported key \(key)")
} }
+5 -2
View File
@@ -664,12 +664,15 @@ func (d *Driver) runnerTyper() transport.TextTyper {
} }
// 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 // Return/Enter and Escape are the hardware-keyboard keys the simulator has;
// names (notably "back" and "home") have no HID key and report unsupported. // other names (notably "back" and "home") have no HID key and report
// unsupported.
func pressKeyUsage(key string) (uint32, bool) { func pressKeyUsage(key string) (uint32, bool) {
switch key { switch key {
case "enter", "return", "Enter", "Return": case "enter", "return", "Enter", "Return":
return usageReturn, true return usageReturn, true
case "escape", "Escape":
return usageEscape, true
default: default:
return 0, false return 0, false
} }
+1
View File
@@ -9,6 +9,7 @@ const (
usage1 = 30 usage1 = 30
usage0 = 39 usage0 = 39
usageReturn = 40 usageReturn = 40
usageEscape = 41
usageTab = 43 usageTab = 43
usageSpace = 44 usageSpace = 44
usageBackspace = 42 usageBackspace = 42
@@ -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)
}
}
}
@@ -336,7 +336,13 @@ func (c *runnerCompanion) PressKey(ctx context.Context, key string) error {
case "enter", "return", "Enter", "Return": case "enter", "return", "Enter", "Return":
_, err := c.call(ctx, "pressKey", map[string]any{"key": "return"}) _, err := c.call(ctx, "pressKey", map[string]any{"key": "return"})
return err return err
case "escape", "Escape":
_, err := c.call(ctx, "pressKey", map[string]any{"key": "escape"})
return err
default: 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,
)
} }
} }
@@ -58,7 +58,7 @@ type TextEditor interface {
// EraseText deletes characterCount characters from the focused field. // EraseText deletes characterCount characters from the focused field.
EraseText(ctx context.Context, characterCount int) error 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 PressKey(ctx context.Context, key string) error
} }
+1
View File
@@ -184,6 +184,7 @@ export type Key =
| "home" | "home"
| "enter" | "enter"
| "tab" | "tab"
| "escape"
| "up" | "up"
| "down" | "down"
| "left" | "left"
+21
View File
@@ -38,6 +38,7 @@ import type {
EventuallyFormula, EventuallyFormula,
Extracted, Extracted,
Formula, Formula,
Key,
State, State,
SanderlingRuntime, SanderlingRuntime,
} from "../src/types.ts"; } from "../src/types.ts";
@@ -273,6 +274,26 @@ test("PressKey returns a PressKeyAction descriptor", () => {
assert.deepEqual(PressKey({ key: "back" }), { kind: "PressKey", key: "back" }); 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", () => { test("Wait returns a WaitAction descriptor", () => {
assert.deepEqual(Wait({ durationMillis: 500 }), { kind: "Wait", durationMillis: 500 }); assert.deepEqual(Wait({ durationMillis: 500 }), { kind: "Wait", durationMillis: 500 });
}); });
+2 -2
View File
@@ -623,8 +623,8 @@ func (x *SwipeRequest) GetDurationMillis() int64 {
type PressKeyRequest struct { type PressKeyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Logical key name. Supported: "back", "home", "enter", "tab", "up", // Logical key name. Supported: "back", "home", "enter", "tab", "escape",
// "down", "left", "right". // "up", "down", "left", "right".
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
+2 -2
View File
@@ -95,8 +95,8 @@ message SwipeRequest {
} }
message PressKeyRequest { message PressKeyRequest {
// Logical key name. Supported: "back", "home", "enter", "tab", "up", // Logical key name. Supported: "back", "home", "enter", "tab", "escape",
// "down", "left", "right". // "up", "down", "left", "right".
string key = 1; string key = 1;
} }
@@ -513,6 +513,7 @@ class StubDriverBackend(
"home" to "KEYCODE_HOME", "home" to "KEYCODE_HOME",
"enter" to "KEYCODE_ENTER", "enter" to "KEYCODE_ENTER",
"tab" to "KEYCODE_TAB", "tab" to "KEYCODE_TAB",
"escape" to "KEYCODE_ESCAPE",
"up" to "KEYCODE_DPAD_UP", "up" to "KEYCODE_DPAD_UP",
"down" to "KEYCODE_DPAD_DOWN", "down" to "KEYCODE_DPAD_DOWN",
"left" to "KEYCODE_DPAD_LEFT", "left" to "KEYCODE_DPAD_LEFT",
@@ -1207,6 +1208,7 @@ private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? =
"KEYCODE_HOME" -> maestro.KeyCode.HOME "KEYCODE_HOME" -> maestro.KeyCode.HOME
"KEYCODE_ENTER" -> maestro.KeyCode.ENTER "KEYCODE_ENTER" -> maestro.KeyCode.ENTER
"KEYCODE_TAB" -> maestro.KeyCode.TAB "KEYCODE_TAB" -> maestro.KeyCode.TAB
"KEYCODE_ESCAPE" -> maestro.KeyCode.ESCAPE
"KEYCODE_DPAD_UP" -> maestro.KeyCode.REMOTE_UP "KEYCODE_DPAD_UP" -> maestro.KeyCode.REMOTE_UP
"KEYCODE_DPAD_DOWN" -> maestro.KeyCode.REMOTE_DOWN "KEYCODE_DPAD_DOWN" -> maestro.KeyCode.REMOTE_DOWN
"KEYCODE_DPAD_LEFT" -> maestro.KeyCode.REMOTE_LEFT "KEYCODE_DPAD_LEFT" -> maestro.KeyCode.REMOTE_LEFT
@@ -212,4 +212,17 @@ class InputTextTest {
assertEquals(maestro.KeyCode.ENTER, maestroKeyFor("enter")) assertEquals(maestro.KeyCode.ENTER, maestroKeyFor("enter"))
assertFailsWith<IllegalArgumentException> { maestroKeyFor("zorp") } assertFailsWith<IllegalArgumentException> { 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<List<String>>()
StubDriverBackend("android") { commands.add(it) }.pressKey("escape")
assertEquals(
listOf(listOf("shell", "input", "keyevent", "KEYCODE_ESCAPE")),
commands,
)
assertEquals(maestro.KeyCode.ESCAPE, maestroKeyFor("escape"))
}
} }