Skip to content

Commit a5fffc6

Browse files
authored
feat: add Maestro YAML replay compatibility (#581)
* feat: add Maestro replay compatibility * fix: harden Maestro replay compatibility * fix: address Maestro replay review feedback * fix: finalize Maestro replay compatibility * refactor: tighten Maestro replay compatibility boundary * refactor: reduce Maestro replay quality debt * refactor: satisfy Maestro replay fallow audit * fix: stabilize Android provider snapshot tests
1 parent 25c7ade commit a5fffc6

109 files changed

Lines changed: 8024 additions & 1386 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android-snapshot-helper/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ VERSION="$(node -p 'require("./package.json").version')"
3131
adb install -r -t ".tmp/android-snapshot-helper/agent-device-android-snapshot-helper-$VERSION.apk"
3232
adb shell am instrument -w \
3333
-e waitForIdleTimeoutMs 500 \
34+
-e waitForIdleQuietMs 100 \
3435
-e timeoutMs 8000 \
3536
-e maxDepth 128 \
3637
-e maxNodes 5000 \
@@ -59,6 +60,7 @@ The final instrumentation result includes:
5960
- `ok=true`
6061
- `helperApiVersion=1`
6162
- `waitForIdleTimeoutMs`
63+
- `waitForIdleQuietMs`
6264
- `timeoutMs`
6365
- `maxDepth`
6466
- `maxNodes`

android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
import android.util.Base64;
99
import android.view.accessibility.AccessibilityNodeInfo;
1010
import android.view.accessibility.AccessibilityWindowInfo;
11+
import java.io.File;
12+
import java.io.FileOutputStream;
13+
import java.io.IOException;
14+
import java.lang.reflect.Field;
1115
import java.nio.charset.StandardCharsets;
1216
import java.util.List;
1317
import java.util.Locale;
@@ -17,8 +21,9 @@ public final class SnapshotInstrumentation extends Instrumentation {
1721
private static final String PROTOCOL = "android-snapshot-helper-v1";
1822
private static final String OUTPUT_FORMAT = "uiautomator-xml";
1923
private static final String HELPER_API_VERSION = "1";
20-
private static final int CHUNK_SIZE = 8 * 1024;
24+
private static final int CHUNK_SIZE = 2 * 1024;
2125
private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 500;
26+
private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 100;
2227
private static final long DEFAULT_TIMEOUT_MS = 8_000;
2328
private static final int DEFAULT_MAX_DEPTH = 128;
2429
private static final int DEFAULT_MAX_NODES = 5_000;
@@ -36,21 +41,27 @@ public void onStart() {
3641
super.onStart();
3742
long waitForIdleTimeoutMs =
3843
readLongArgument(arguments, "waitForIdleTimeoutMs", DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS);
44+
long waitForIdleQuietMs =
45+
readLongArgument(arguments, "waitForIdleQuietMs", DEFAULT_WAIT_FOR_IDLE_QUIET_MS);
3946
long timeoutMs = readLongArgument(arguments, "timeoutMs", DEFAULT_TIMEOUT_MS);
4047
int maxDepth = readIntArgument(arguments, "maxDepth", DEFAULT_MAX_DEPTH);
4148
int maxNodes = readIntArgument(arguments, "maxNodes", DEFAULT_MAX_NODES);
49+
String outputPath = readStringArgument(arguments, "outputPath");
4250
Bundle result = new Bundle();
4351
result.putString("agentDeviceProtocol", PROTOCOL);
4452
result.putString("helperApiVersion", HELPER_API_VERSION);
4553
result.putString("outputFormat", OUTPUT_FORMAT);
4654
result.putString("waitForIdleTimeoutMs", Long.toString(waitForIdleTimeoutMs));
55+
result.putString("waitForIdleQuietMs", Long.toString(waitForIdleQuietMs));
4756
result.putString("timeoutMs", Long.toString(timeoutMs));
4857
result.putString("maxDepth", Integer.toString(maxDepth));
4958
result.putString("maxNodes", Integer.toString(maxNodes));
5059

5160
try {
5261
long startedAtMs = System.currentTimeMillis();
53-
CaptureResult capture = captureXml(waitForIdleTimeoutMs, maxDepth, maxNodes);
62+
CaptureResult capture =
63+
captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
64+
writeOutputFile(outputPath, capture.xml);
5465
emitChunks(capture.xml);
5566
result.putString("ok", "true");
5667
result.putString("rootPresent", Boolean.toString(capture.rootPresent));
@@ -59,26 +70,111 @@ public void onStart() {
5970
result.putString("nodeCount", Integer.toString(capture.nodeCount));
6071
result.putString("truncated", Boolean.toString(capture.truncated));
6172
result.putString("elapsedMs", Long.toString(System.currentTimeMillis() - startedAtMs));
62-
finish(0, result);
73+
finishSafely(0, result);
6374
} catch (Throwable error) {
6475
result.putString("ok", "false");
6576
result.putString("errorType", error.getClass().getName());
6677
result.putString(
6778
"message",
6879
error.getMessage() == null ? error.getClass().getName() : error.getMessage());
69-
finish(1, result);
80+
finishSafely(1, result);
81+
}
82+
}
83+
84+
private static String readStringArgument(Bundle arguments, String key) {
85+
if (arguments == null || !arguments.containsKey(key)) {
86+
return null;
87+
}
88+
String value = arguments.getString(key);
89+
return value == null || value.trim().isEmpty() ? null : value.trim();
90+
}
91+
92+
private static void writeOutputFile(String outputPath, String xml) throws IOException {
93+
if (outputPath == null) {
94+
return;
95+
}
96+
File file = new File(outputPath);
97+
File parent = file.getParentFile();
98+
if (parent != null) {
99+
parent.mkdirs();
100+
}
101+
try (FileOutputStream stream = new FileOutputStream(file, false)) {
102+
stream.write(xml.getBytes(StandardCharsets.UTF_8));
103+
}
104+
}
105+
106+
private void finishSafely(int resultCode, Bundle result) {
107+
RuntimeException lastError = null;
108+
for (int attempt = 0; attempt < 100; attempt += 1) {
109+
try {
110+
finish(resultCode, result);
111+
return;
112+
} catch (IllegalStateException error) {
113+
if (!isUiAutomationConnectingError(error)) {
114+
throw error;
115+
}
116+
lastError = error;
117+
sleep(100);
118+
}
119+
}
120+
detachUiAutomationBeforeFinish();
121+
try {
122+
finish(resultCode, result);
123+
return;
124+
} catch (IllegalStateException error) {
125+
if (!isUiAutomationConnectingError(error)) {
126+
throw error;
127+
}
128+
lastError = error;
129+
}
130+
throw lastError;
131+
}
132+
133+
private void detachUiAutomationBeforeFinish() {
134+
try {
135+
Field field = Instrumentation.class.getDeclaredField("mUiAutomation");
136+
field.setAccessible(true);
137+
field.set(this, null);
138+
} catch (ReflectiveOperationException | RuntimeException ignored) {
139+
// If the platform blocks reflection, preserve the original finish failure below.
140+
}
141+
}
142+
143+
private static boolean isUiAutomationConnectingError(IllegalStateException error) {
144+
String message = error.getMessage();
145+
return message != null && message.contains("while connecting");
146+
}
147+
148+
private static boolean isUiAutomationNotConnectedError(IllegalStateException error) {
149+
String message = error.getMessage();
150+
return message != null && message.toLowerCase(Locale.ROOT).contains("not connected");
151+
}
152+
153+
private static void sleep(long millis) {
154+
try {
155+
Thread.sleep(millis);
156+
} catch (InterruptedException error) {
157+
Thread.currentThread().interrupt();
70158
}
71159
}
72160

73161
@SuppressWarnings("deprecation")
74-
private CaptureResult captureXml(long waitForIdleTimeoutMs, int maxDepth, int maxNodes)
162+
private CaptureResult captureXml(
163+
long waitForIdleQuietMs,
164+
long waitForIdleTimeoutMs,
165+
long timeoutMs,
166+
int maxDepth,
167+
int maxNodes)
75168
throws TimeoutException {
76-
UiAutomation automation = getUiAutomation();
169+
UiAutomation automation = getConnectedUiAutomation(timeoutMs);
77170
enableInteractiveWindowRetrieval(automation);
78171
if (waitForIdleTimeoutMs > 0) {
79172
try {
80-
// Best-effort settle: avoids empty roots without inheriting UIAutomator's long idle wait.
81-
automation.waitForIdle(waitForIdleTimeoutMs, waitForIdleTimeoutMs);
173+
// Best-effort settle: wait for the accessibility stream to become idle, but require only
174+
// a short quiet window once it does. Using the full timeout as the quiet window made every
175+
// stable snapshot pay a fixed 500 ms tax.
176+
long quietMs = Math.min(waitForIdleQuietMs, waitForIdleTimeoutMs);
177+
automation.waitForIdle(quietMs, waitForIdleTimeoutMs);
82178
} catch (TimeoutException ignored) {
83179
// Busy or animated apps can still expose a usable root; capture whatever is available.
84180
}
@@ -109,6 +205,30 @@ private CaptureResult captureXml(long waitForIdleTimeoutMs, int maxDepth, int ma
109205
xml.toString(), windowCount > 0, captureMode, windowCount, stats.nodeCount, stats.truncated);
110206
}
111207

208+
private UiAutomation getConnectedUiAutomation(long timeoutMs) throws TimeoutException {
209+
long deadlineMs = System.currentTimeMillis() + Math.max(1, timeoutMs);
210+
UiAutomation automation = getUiAutomation();
211+
RuntimeException lastError = null;
212+
while (System.currentTimeMillis() <= deadlineMs) {
213+
try {
214+
automation.getServiceInfo();
215+
return automation;
216+
} catch (IllegalStateException error) {
217+
if (!isUiAutomationConnectingError(error) && !isUiAutomationNotConnectedError(error)) {
218+
throw error;
219+
}
220+
lastError = error;
221+
}
222+
sleep(50);
223+
}
224+
TimeoutException timeout =
225+
new TimeoutException("Timed out waiting for Android UiAutomation to connect");
226+
if (lastError != null) {
227+
timeout.initCause(lastError);
228+
}
229+
throw timeout;
230+
}
231+
112232
private static void enableInteractiveWindowRetrieval(UiAutomation automation) {
113233
AccessibilityServiceInfo serviceInfo;
114234
try {

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

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,12 @@ extension RunnerTests {
252252
)
253253
case .tap:
254254
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
255-
let match = findElement(app: activeApp, selectorKey: selectorKey, selectorValue: selectorValue)
255+
let match = findElement(
256+
app: activeApp,
257+
selectorKey: selectorKey,
258+
selectorValue: selectorValue,
259+
allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true
260+
)
256261
if match.isAmbiguous {
257262
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
258263
}
@@ -264,16 +269,24 @@ extension RunnerTests {
264269
var outcome = RunnerInteractionOutcome.performed
265270
let timing = measureGesture {
266271
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
267-
outcome = activateElement(app: activeApp, element: element, action: "tap by selector")
272+
if match.usedNonHittableFallback {
273+
// Maestro compatibility: RN E2E backdoor controls can be 1x1 and
274+
// reported non-hittable by XCTest, while Maestro still taps their
275+
// resolved bounds. Keep this behind the explicit replay-only flag.
276+
outcome = tapAt(app: activeApp, x: frame.midX, y: frame.midY)
277+
} else {
278+
outcome = activateElement(app: activeApp, element: element, action: "tap by selector")
279+
}
268280
}
269281
}
270282
if let response = unsupportedResponse(for: outcome) {
271283
return response
272284
}
285+
waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
273286
return Response(
274287
ok: true,
275288
data: DataPayload(
276-
message: "tapped",
289+
message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
277290
gestureStartUptimeMs: timing.gestureStartUptimeMs,
278291
gestureEndUptimeMs: timing.gestureEndUptimeMs,
279292
x: touchFrame?.x,
@@ -729,6 +742,25 @@ extension RunnerTests {
729742
dismissed: result.dismissed
730743
)
731744
)
745+
case .keyboardReturn:
746+
let result = pressKeyboardReturn(app: activeApp)
747+
if !result.pressed {
748+
return Response(
749+
ok: false,
750+
error: ErrorPayload(
751+
code: "UNSUPPORTED_OPERATION",
752+
message: "Unable to press the iOS keyboard return key"
753+
)
754+
)
755+
}
756+
return Response(
757+
ok: true,
758+
data: DataPayload(
759+
message: "keyboardReturn",
760+
visible: result.visible,
761+
wasVisible: result.wasVisible
762+
)
763+
)
732764
case .alert:
733765
let action = (command.action ?? "get").lowercased()
734766
guard let alert = resolveAlert(app: activeApp) else {
@@ -839,7 +871,27 @@ extension RunnerTests {
839871
}
840872
let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
841873
let textEntryMode = resolveTextEntryMode(command)
842-
let target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
874+
let target: TextEntryTarget
875+
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
876+
let match = findElement(
877+
app: activeApp,
878+
selectorKey: selectorKey,
879+
selectorValue: selectorValue,
880+
allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true
881+
)
882+
if match.isAmbiguous {
883+
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
884+
}
885+
guard let element = match.element else {
886+
return Response(ok: false, error: ErrorPayload(code: "NO_MATCH", message: "selector did not match an element"))
887+
}
888+
guard isTextEntryElement(element) else {
889+
return Response(ok: false, error: ErrorPayload(code: "INVALID_TARGET", message: "selector did not match a text input"))
890+
}
891+
target = focusTextInputForTextEntry(app: activeApp, element: element)
892+
} else {
893+
target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
894+
}
843895
if textEntryMode == .replacement {
844896
guard target.element != nil else {
845897
let message =
@@ -867,6 +919,17 @@ extension RunnerTests {
867919
)
868920
)
869921
}
870-
return Response(ok: true, data: DataPayload(message: textResult.repaired ? "typed after repair" : "typed"))
922+
let point = target.refreshPoint
923+
let frame = activeApp.frame
924+
return Response(
925+
ok: true,
926+
data: DataPayload(
927+
message: textResult.repaired ? "typed after repair" : "typed",
928+
x: point.map { Double($0.x) },
929+
y: point.map { Double($0.y) },
930+
referenceWidth: frame.isEmpty ? nil : Double(frame.width),
931+
referenceHeight: frame.isEmpty ? nil : Double(frame.height)
932+
)
933+
)
871934
}
872935
}

0 commit comments

Comments
 (0)