Files
sanderling/companion/Sources/TextInput.swift
T
pj da3cc5f68c 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.
2026-08-16 17:41:33 +05:30

104 lines
4.2 KiB
Swift

import XCTest
// Types unicode text into the focused element through the public typeText API,
// avoiding the pasteboard entirely. With replace enabled, the current value
// length is consumed first by prepending that many delete keys so the whole
// replacement happens in one typeText call.
enum TextInput {
enum TextInputError: Error {
case typingFailed(String)
}
static func type(text: String, replace: Bool, bundleIdentifier: String) throws {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
var payload = text
if replace {
let deleteCount = deletePrefixLength(bundleIdentifier: bundleIdentifier)
if deleteCount > 0 {
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: deleteCount)
payload = deletes + text
}
}
// Type at the application level so it lands in whatever holds keyboard
// focus, without resolving a specific element. Compose Multiplatform
// text fields do not reliably expose hasKeyboardFocus to the query layer.
// typeText must run on the main thread.
var caughtError: NSError?
var completed = false
runOnMain {
completed = CompanionRunCatching({
application.typeText(payload)
}, &caughtError)
}
if !completed {
throw TextInputError.typingFailed(caughtError?.localizedDescription ?? "unknown")
}
}
// erase deletes count characters from the focused field by typing that many
// delete keys. Used on device, where EraseText routes to the runner instead
// of the legacy companion's HID stream.
static func erase(count: Int, bundleIdentifier: String) throws {
guard count > 0 else { return }
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
try typeOnFocus(deletes, bundleIdentifier: bundleIdentifier)
}
// 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)")
}
}
private static func typeOnFocus(_ payload: String, bundleIdentifier: String) throws {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
var caughtError: NSError?
var completed = false
runOnMain {
completed = CompanionRunCatching({
application.typeText(payload)
}, &caughtError)
}
if !completed {
throw TextInputError.typingFailed(caughtError?.localizedDescription ?? "unknown")
}
}
private static func runOnMain(_ work: () -> Void) {
if Thread.isMainThread {
work()
} else {
DispatchQueue.main.sync(execute: work)
}
}
// Sizes the delete prefix for replace. The snapshot does not say which
// editable field holds keyboard focus (Compose fields never expose
// hasKeyboardFocus), so the prefix covers the longest editable value on
// screen: deletes beyond the focused field's content are no-ops at the
// start of the field, while undersizing would leave residue and silently
// turn replace into append.
private static func deletePrefixLength(bundleIdentifier: String) -> Int {
let elements = Snapshot.elements(bundleIdentifier: bundleIdentifier)
var longest = 0
for element in elements {
guard let type = element["type"] as? String,
type == "TextArea" || type == "TextField",
let value = element["AXValue"] as? String else {
continue
}
longest = max(longest, value.count)
}
return longest
}
}