fix(companion): type on the main thread and recover from focus assertions

This commit is contained in:
pj committed 2026-06-07 15:59:15 +05:30
1 parent 76cbf1a102
commit fc215695a2
2 files changed
+51 -14

No files matched your search

@@ -35,4 +35,20 @@ NS_ASSUME_NONNULL_BEGIN
completion:(void (^)(NSError *_Nullable error))completion;
@end
// Runs the block and converts any raised NSException into an NSError so a
// testing-framework assertion does not terminate the runner process.
NS_INLINE BOOL CompanionRunCatching(void (^block)(void), NSError *_Nullable *_Nullable error) {
@try {
block();
return YES;
} @catch (NSException *exception) {
if (error) {
*error = [NSError errorWithDomain:@"dev.sanderling.companion"
code:1
userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: exception.name}];
}
return NO;
}
}
NS_ASSUME_NONNULL_END
+35 -14
View File
@@ -5,39 +5,60 @@ import XCTest
// 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)
let focused = focusedElement(in: application)
var payload = text
if replace {
let currentLength = currentValueLength(of: focused)
let currentLength = focusedValueLength(bundleIdentifier: bundleIdentifier)
if currentLength > 0 {
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentLength)
payload = deletes + text
}
}
if let focused = focused {
focused.typeText(payload)
} else {
// 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")
}
}
private static func focusedElement(in application: XCUIApplication) -> XCUIElement? {
let predicate = NSPredicate(format: "hasKeyboardFocus == true")
let matches = application.descendants(matching: .any).matching(predicate)
if matches.count > 0 {
return matches.element(boundBy: 0)
private static func runOnMain(_ work: () -> Void) {
if Thread.isMainThread {
work()
} else {
DispatchQueue.main.sync(execute: work)
}
return nil
}
private static func currentValueLength(of element: XCUIElement?) -> Int {
guard let element = element, let value = element.value as? String else {
return 0
// Reads the current value length of the focused editable field from a flat
// snapshot. Used to size the delete prefix for replace.
private static func focusedValueLength(bundleIdentifier: String) -> Int {
let elements = Snapshot.elements(bundleIdentifier: bundleIdentifier)
for element in elements {
guard let type = element["type"] as? String,
type == "TextArea" || type == "TextField",
let value = element["AXValue"] as? String else {
continue
}
if !value.isEmpty {
return value.count
}
}
return 0
}
}