Skip to content

Commit dc030ae

Browse files
committed
refactor: satisfy Maestro replay fallow audit
1 parent 5331372 commit dc030ae

80 files changed

Lines changed: 5072 additions & 1749 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+Interaction.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,8 @@ extension RunnerTests {
375375

376376
func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
377377
#if os(iOS)
378+
// iOS focus predicates can return stale or misleading text-input matches
379+
// under XCUITest, so text entry readiness is driven by tap/keyboard state.
378380
return nil
379381
#else
380382
var focused: XCUIElement?

src/cli/commands/client-command.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import type { CliFlags } from '../../utils/command-schema.ts';
1111
import { AppError } from '../../utils/errors.ts';
1212
import { readCommandMessage } from '../../utils/success-text.ts';
13+
import { isKeyboardAction } from '../../utils/keyboard-actions.ts';
1314
import { waitCommandCodec } from '../../command-codecs.ts';
1415
import { parseDeviceRotation } from '../../core/device-rotation.ts';
1516
import { buildSelectionOptions, writeCommandMessage, writeCommandOutput } from './shared.ts';
@@ -173,13 +174,7 @@ function readKeyboardAction(
173174
): KeyboardCommandOptions['action'] | undefined {
174175
const action = value?.toLowerCase();
175176
if (action === 'get') return 'status';
176-
if (
177-
action === undefined ||
178-
action === 'status' ||
179-
action === 'dismiss' ||
180-
action === 'enter' ||
181-
action === 'return'
182-
) {
177+
if (action === undefined || (isKeyboardAction(action) && action !== 'get')) {
183178
return action;
184179
}
185180
throw new AppError(

src/cli/commands/generic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const genericClientCommandRunners = {
6969
...buildSelectionOptions(flags),
7070
paths: positionals,
7171
update: flags.replayUpdate,
72+
backend: flags.replayMaestro ? 'maestro' : undefined,
7273
env: flags.replayEnv,
7374
failFast: flags.failFast,
7475
timeoutMs: flags.timeoutMs,

src/client-types.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -674,20 +674,24 @@ export type FindOptions =
674674
| (FindBaseOptions & { action: 'wait'; timeoutMs?: number })
675675
| (FindBaseOptions & { action: 'fill' | 'type'; value: string });
676676

677-
export type ReplayRunOptions = AgentDeviceRequestOverrides & {
678-
path: string;
679-
update?: boolean;
680-
/** @deprecated Use backend: 'maestro'. */
681-
maestro?: boolean;
682-
backend?: string;
683-
env?: string[];
684-
timeoutMs?: number;
685-
};
677+
export type ReplayRunOptions = AgentDeviceRequestOverrides &
678+
AgentDeviceSelectionOptions & {
679+
path: string;
680+
update?: boolean;
681+
/** @deprecated Use backend: 'maestro'. */
682+
maestro?: boolean;
683+
backend?: string;
684+
env?: string[];
685+
timeoutMs?: number;
686+
};
686687

687688
export type ReplayTestOptions = AgentDeviceRequestOverrides &
688689
AgentDeviceSelectionOptions & {
689690
paths: string[];
690691
update?: boolean;
692+
/** @deprecated Use backend: 'maestro'. */
693+
maestro?: boolean;
694+
backend?: string;
691695
env?: string[];
692696
failFast?: boolean;
693697
timeoutMs?: number;

src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ export function createAgentDeviceClient(
456456
await executeCommandRequest(PUBLIC_COMMANDS.test, options.paths, {
457457
...options,
458458
replayUpdate: options.update,
459+
replayBackend: options.backend ?? (options.maestro === true ? 'maestro' : undefined),
459460
replayEnv: options.env,
460461
replayShellEnv: collectReplayClientShellEnv(process.env),
461462
}),

0 commit comments

Comments
 (0)