Skip to content

Commit 2bf0dba

Browse files
committed
fix: improve ios selector reads and maestro reliability
1 parent fa4e2d5 commit 2bf0dba

33 files changed

Lines changed: 1422 additions & 146 deletions

examples/test-app/replays/checkout-form-android.ad

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ context platform=android timeout=60000
22
env APP_TARGET="Agent Device Tester"
33
env APP_URL=""
44
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
5+
react-native dismiss-overlay
56
wait "label=\"Form\"" 30000
67
click "label=\"Form\""
78
wait "Checkout form" 5000
@@ -12,10 +13,11 @@ wait "Checkout form" 5000
1213
scroll down 0.6
1314
click id="shipping-pickup"
1415
click id="payment-cash"
15-
wait "Delivery choices" 5000
16+
wait "Delivery" 5000
1617
scroll down 0.7
1718
click id="checkbox-agree"
1819
click id="submit-order"
20+
scroll up 0.6
1921
wait "Order summary" 5000
2022
wait "Ada Lovelace chose pickup with cash payment." 5000
2123
close

examples/test-app/replays/checkout-form.ad

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ context platform=ios timeout=60000
22
env APP_TARGET="Agent Device Tester"
33
env APP_URL=""
44
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
5+
react-native dismiss-overlay
56
wait "label=\"Form\"" 30000
67
click "label=\"Form\""
78
wait "Checkout form" 5000

examples/test-app/replays/gesture-lab.ad

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ env APP_TARGET="Agent Device Tester"
44
env APP_URL=""
55

66
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
7+
react-native dismiss-overlay
78
wait "Gesture lab" 30000
89

910
gesture fling left 195 443 180

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,13 @@ extension RunnerTests {
283283

284284
func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
285285
let point = CGPoint(x: x, y: y)
286+
let textInputCandidates = textInputCandidatesAt(app: app, point: point)
287+
for element in textInputCandidates where prefersExpandedTextRead(element) {
288+
if let text = readableText(for: element) {
289+
return text
290+
}
291+
}
292+
286293
let candidates = app.descendants(matching: .any).allElementsBoundByIndex
287294
.filter { element in
288295
element.exists && !element.frame.isEmpty && element.frame.contains(point)
@@ -337,15 +344,18 @@ extension RunnerTests {
337344
}
338345

339346
func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
340-
let point = CGPoint(x: x, y: y)
341-
var matched: XCUIElement?
347+
return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first
348+
}
349+
350+
private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
351+
var candidates: [XCUIElement] = []
342352
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
343353
// Query the text-input element types directly instead of enumerating the entire tree
344354
// (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x
345355
// slower — it dominated fill latency because resolveTextEntryElement re-runs this on
346356
// each verify/repair poll once the focused field reference goes stale).
347357
// Prefer the smallest matching field so nested editable controls win over large containers.
348-
let candidates = [
358+
candidates = [
349359
app.textFields,
350360
app.secureTextFields,
351361
app.searchFields,
@@ -371,16 +381,15 @@ extension RunnerTests {
371381
}
372382
return left.elementType.rawValue < right.elementType.rawValue
373383
}
374-
matched = candidates.first
375384
})
376385
if let exceptionMessage {
377386
NSLog(
378387
"AGENT_DEVICE_RUNNER_TEXT_INPUT_AT_POINT_IGNORED_EXCEPTION=%@",
379388
exceptionMessage
380389
)
381-
return nil
390+
return []
382391
}
383-
return matched
392+
return candidates
384393
}
385394

386395
private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool {
@@ -1019,6 +1028,14 @@ extension RunnerTests {
10191028
return (wasVisible: true, dismissed: !visible, visible: visible)
10201029
}
10211030

1031+
if tapKeyboardReturnControl(app: app, allowCoordinateFallback: true) {
1032+
sleepFor(0.2)
1033+
let visible = isKeyboardVisible(app: app)
1034+
if !visible {
1035+
return (wasVisible: true, dismissed: true, visible: false)
1036+
}
1037+
}
1038+
10221039
return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
10231040
#endif
10241041
}
@@ -1139,7 +1156,10 @@ extension RunnerTests {
11391156
#endif
11401157
}
11411158

1142-
private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
1159+
private func tapKeyboardReturnControl(
1160+
app: XCUIApplication,
1161+
allowCoordinateFallback: Bool = false
1162+
) -> Bool {
11431163
#if os(iOS)
11441164
for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
11451165
let candidates = [
@@ -1150,6 +1170,21 @@ extension RunnerTests {
11501170
hittable.tap()
11511171
return true
11521172
}
1173+
if allowCoordinateFallback,
1174+
let keyboardFrame = visibleKeyboardFrame(app: app),
1175+
let framed = candidates.first(where: {
1176+
guard $0.exists else { return false }
1177+
let frame = $0.frame
1178+
return !frame.isEmpty && keyboardFrame.contains(CGPoint(x: frame.midX, y: frame.midY))
1179+
}) {
1180+
let frame = framed.frame
1181+
switch tapAt(app: app, x: frame.midX, y: frame.midY) {
1182+
case .performed:
1183+
return true
1184+
case .unsupported:
1185+
return false
1186+
}
1187+
}
11531188
}
11541189
#endif
11551190
return false

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,25 @@ extension RunnerTests {
110110
#if os(tvOS)
111111
return .unsupported("element tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
112112
#else
113-
element.tap()
113+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
114+
element.tap()
115+
})
116+
if let exceptionMessage {
117+
NSLog("AGENT_DEVICE_RUNNER_ELEMENT_TAP_IGNORED_EXCEPTION=%@", exceptionMessage)
118+
if isPostTapElementDisappearance(exceptionMessage) {
119+
return .performed
120+
}
121+
return .unsupported("element tap failed: \(exceptionMessage)")
122+
}
114123
return .performed
115124
#endif
116125
}
117126

127+
private func isPostTapElementDisappearance(_ message: String) -> Bool {
128+
message.contains("No matches found")
129+
|| message.contains("Failed to get matching snapshot")
130+
}
131+
118132
private func selectFocusedTvElement(app: XCUIApplication, element: XCUIElement, action: String) -> RunnerInteractionOutcome? {
119133
#if os(tvOS)
120134
guard tvFocusedElementMatches(app: app, target: element) else {

scripts/perf/scenario.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,14 @@ export function buildSettingsTour(p: ResolvedProfile, ctx: StepContext): Scenari
5555
// iOS: editable search field exists at root; fill it directly (freshRoot resets scroll).
5656
bat('fill search', 'fill', { command: 'fill', positionals: [s.searchFieldEditable, 'general'] }, { freshRoot: true }),
5757
bat('type', 'type', { command: 'type', positionals: ['wifi'] }),
58+
bat('get editable text', 'get', { command: 'get', positionals: ['text', s.searchFieldEditable] }),
5859
]
5960
: [
6061
// Android: tap the search entry first to reveal the editable, then type/fill it.
6162
bat('press search field', 'press', { command: 'press', positionals: [s.searchField] }, { freshRoot: true }),
6263
bat('type', 'type', { command: 'type', positionals: ['wifi'] }),
6364
bat('fill search', 'fill', { command: 'fill', positionals: [s.searchFieldEditable, 'general'] }),
65+
bat('get editable text', 'get', { command: 'get', positionals: ['text', s.searchFieldEditable] }),
6466
];
6567

6668
return [

src/commands/client-command-metadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export const clientCommandMetadata = [
196196
defineClientCommandMetadata('settings', {
197197
setting: requiredField(stringField()),
198198
state: requiredField(stringField()),
199+
app: stringField(),
199200
latitude: numberField(),
200201
longitude: numberField(),
201202
permission: stringField(),

src/commands/react-native/overlay.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,18 @@ export function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOver
5959
const minimizeRefs = refsOf(minimizeNodes);
6060
const collapsedRefs = refsOf(collapsedNodes);
6161
const hasReactNativeStackFrame = isReactNativeStackFrame(text);
62+
const hasControllessRedBoxText =
63+
/\buncaught\b/.test(text) && /unable to download asset/.test(text);
6264
const hasOverlayControl =
6365
dismissRefs.length > 0 || minimizeRefs.length > 0 || /\b(reload js|copy stack)\b/.test(text);
6466
const redBox =
6567
/\b(redbox|runtime error|reload js|copy stack|component stack|call stack)\b/.test(text) ||
68+
hasControllessRedBoxText ||
6669
(hasReactNativeStackFrame && hasOverlayControl);
6770
const detected =
6871
collapsedRefs.length > 0 ||
6972
openDebuggerWarningNodes.length > 0 ||
73+
hasControllessRedBoxText ||
7074
(hasOverlayControl && (hasKnownReactNativeOverlayText(text) || hasReactNativeStackFrame));
7175
return {
7276
detected,

src/compat/maestro/__tests__/replay-flow.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ env:
9191
assert.equal(parsed.actions[4]?.flags.holdMs, 3000);
9292
assert.equal(parsed.actions[1]?.flags.maestro?.allowNonHittableCoordinateFallback, true);
9393
assert.equal(parsed.actions[6]?.flags?.maestro?.allowNonHittableCoordinateFallback, true);
94+
assert.equal(parsed.actions[10]?.flags.maestro?.allowAlreadyPastLoading, true);
9495
});
9596

9697
test('parseMaestroReplayFlow maps iOS openLink through the app id when available', () => {
@@ -106,6 +107,7 @@ test('parseMaestroReplayFlow maps iOS openLink through the app id when available
106107
parsed.actions.map((entry) => [entry.command, entry.positionals]),
107108
[['open', ['com.callstack.agentdevicelab', 'exp://localhost:8082']]],
108109
);
110+
assert.equal(parsed.actions[0]?.flags.maestro?.prewarmRunnerBeforeOpen, true);
109111
});
110112

111113
test('parseMaestroReplayFlow maps Android openLink through the app id when available', () => {

0 commit comments

Comments
 (0)