Skip to content

Commit ece193e

Browse files
authored
fix: improve React Navigation Maestro reliability (#628)
1 parent 8e1f8a9 commit ece193e

58 files changed

Lines changed: 2147 additions & 215 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/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ public final class SnapshotInstrumentation extends Instrumentation {
2929
private static final String OUTPUT_FORMAT = "uiautomator-xml";
3030
private static final String HELPER_API_VERSION = "1";
3131
private static final int CHUNK_SIZE = 2 * 1024;
32-
// Keep the default quiet window short: RN/animation-heavy apps often never become fully idle,
33-
// and callers can still override this for alert-style flows that need a longer settle period.
34-
private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 25;
35-
private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 25;
32+
// Match the host defaults: long enough to avoid mid-transition RN snapshots, but still bounded
33+
// below the stock uiautomator idle wait so busy apps do not stall every capture.
34+
private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 500;
35+
private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 100;
3636
private static final long DEFAULT_TIMEOUT_MS = 8_000;
3737
private static final int DEFAULT_MAX_DEPTH = 128;
3838
private static final int DEFAULT_MAX_NODES = 5_000;
@@ -351,7 +351,7 @@ private CaptureResult captureXml(
351351
AccessibilityNodeInfo root = automation.getRootInActiveWindow();
352352
try {
353353
if (root != null) {
354-
appendNode(xml, root, 0, 0, maxDepth, maxNodes, stats);
354+
appendNode(xml, root, 0, 0, maxDepth, maxNodes, stats, null);
355355
windowCount = 1;
356356
}
357357
captureMode = "active-window";
@@ -439,7 +439,15 @@ private static int appendInteractiveWindowRoots(
439439
}
440440
StringBuilder windowXml = new StringBuilder();
441441
CaptureStats windowStats = stats.copy();
442-
appendNode(windowXml, root, windowCount, 0, maxDepth, maxNodes, windowStats);
442+
appendNode(
443+
windowXml,
444+
root,
445+
windowCount,
446+
0,
447+
maxDepth,
448+
maxNodes,
449+
windowStats,
450+
readWindowMetadata(window, windowCount));
443451
xml.append(windowXml);
444452
stats.copyFrom(windowStats);
445453
windowCount += 1;
@@ -482,7 +490,8 @@ private static void appendNode(
482490
int depth,
483491
int maxDepth,
484492
int maxNodes,
485-
CaptureStats stats) {
493+
CaptureStats stats,
494+
WindowMetadata windowMetadata) {
486495
if (stats.nodeCount >= maxNodes) {
487496
stats.truncated = true;
488497
return;
@@ -495,11 +504,15 @@ private static void appendNode(
495504
// without affecting current snapshot semantics; add fields back here when TS starts reading
496505
// them.
497506
appendAttribute(xml, "index", Integer.toString(nodeIndex));
507+
if (windowMetadata != null) {
508+
appendWindowMetadata(xml, windowMetadata);
509+
}
498510
appendNonEmptyAttribute(xml, "text", node.getText());
499511
appendNonEmptyAttribute(xml, "resource-id", node.getViewIdResourceName());
500512
appendAttribute(xml, "class", node.getClassName());
501513
appendNonEmptyAttribute(xml, "package", node.getPackageName());
502514
appendNonEmptyAttribute(xml, "content-desc", node.getContentDescription());
515+
appendAttribute(xml, "visible-to-user", Boolean.toString(node.isVisibleToUser()));
503516
appendTrueAttribute(xml, "clickable", node.isClickable());
504517
appendAttribute(xml, "enabled", Boolean.toString(node.isEnabled()));
505518
appendTrueAttribute(xml, "focusable", node.isFocusable());
@@ -550,7 +563,7 @@ private static void appendNode(
550563
continue;
551564
}
552565
try {
553-
appendNode(xml, child, index, depth + 1, maxDepth, maxNodes, stats);
566+
appendNode(xml, child, index, depth + 1, maxDepth, maxNodes, stats, null);
554567
} finally {
555568
child.recycle();
556569
}
@@ -571,6 +584,32 @@ private static void appendTrueAttribute(StringBuilder xml, String name, boolean
571584
}
572585
}
573586

587+
private static void appendWindowMetadata(StringBuilder xml, WindowMetadata metadata) {
588+
appendAttribute(xml, "window-index", Integer.toString(metadata.index));
589+
appendAttribute(xml, "window-type", Integer.toString(metadata.type));
590+
appendAttribute(xml, "window-layer", Integer.toString(metadata.layer));
591+
appendAttribute(xml, "window-active", Boolean.toString(metadata.active));
592+
appendAttribute(xml, "window-focused", Boolean.toString(metadata.focused));
593+
appendAttribute(
594+
xml,
595+
"window-bounds",
596+
String.format(
597+
Locale.ROOT,
598+
"[%d,%d][%d,%d]",
599+
metadata.bounds.left,
600+
metadata.bounds.top,
601+
metadata.bounds.right,
602+
metadata.bounds.bottom));
603+
}
604+
605+
@SuppressWarnings("deprecation")
606+
private static WindowMetadata readWindowMetadata(AccessibilityWindowInfo window, int index) {
607+
Rect bounds = new Rect();
608+
window.getBoundsInScreen(bounds);
609+
return new WindowMetadata(
610+
index, window.getType(), window.getLayer(), window.isActive(), window.isFocused(), bounds);
611+
}
612+
574613
private static void appendAttribute(StringBuilder xml, String name, CharSequence value) {
575614
String stringValue = value == null ? "" : value.toString();
576615
xml.append(' ');
@@ -702,4 +741,22 @@ private static final class CaptureResult {
702741
this.truncated = truncated;
703742
}
704743
}
744+
745+
private static final class WindowMetadata {
746+
final int index;
747+
final int type;
748+
final int layer;
749+
final boolean active;
750+
final boolean focused;
751+
final Rect bounds;
752+
753+
WindowMetadata(int index, int type, int layer, boolean active, boolean focused, Rect bounds) {
754+
this.index = index;
755+
this.type = type;
756+
this.layer = layer;
757+
this.active = active;
758+
this.focused = focused;
759+
this.bounds = bounds;
760+
}
761+
}
705762
}

src/__tests__/cli-grammar.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,13 @@ test('settings grammar owns positional parsing for CLI commands', () => {
7979
assert.equal(location.state, 'set');
8080
assert.equal(location.latitude, 37.3349);
8181
assert.equal(location.longitude, -122.009);
82+
83+
const clearAppState = readInputFromCli('settings', ['clear-app-state', 'com.example.app'], {
84+
...BASE_FLAGS,
85+
platform: 'android',
86+
});
87+
assert.equal(clearAppState.platform, 'android');
88+
assert.equal(clearAppState.setting, 'clear-app-state');
89+
assert.equal(clearAppState.state, 'clear');
90+
assert.equal(clearAppState.app, 'com.example.app');
8291
});

src/__tests__/runtime-interactions.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,36 @@ test('runtime directional swipe uses the visible viewport instead of off-screen
589589
assert.deepEqual(calls, [{ from: { x: 50, y: 50 }, to: { x: 25, y: 50 } }]);
590590
});
591591

592+
test('runtime gesture swipe presets use stable viewport lanes', async () => {
593+
const calls: unknown[] = [];
594+
const device = createInteractionDevice(snapshotWithOffscreenContent(), {
595+
platform: 'android',
596+
swipe: async (_context, from, to, options) => {
597+
calls.push({ from, to, durationMs: options?.durationMs });
598+
},
599+
});
600+
601+
const pageSwipe = await device.interactions.swipe({
602+
preset: 'left',
603+
durationMs: 300,
604+
session: 'default',
605+
});
606+
const edgeSwipe = await device.interactions.swipe({
607+
preset: 'right-edge',
608+
durationMs: 350,
609+
session: 'default',
610+
});
611+
612+
assert.deepEqual(pageSwipe.from, { x: 90, y: 65 });
613+
assert.deepEqual(pageSwipe.to, { x: 10, y: 65 });
614+
assert.deepEqual(edgeSwipe.from, { x: 8, y: 50 });
615+
assert.deepEqual(edgeSwipe.to, { x: 85, y: 50 });
616+
assert.deepEqual(calls, [
617+
{ from: { x: 90, y: 65 }, to: { x: 10, y: 65 }, durationMs: 300 },
618+
{ from: { x: 8, y: 50 }, to: { x: 85, y: 50 }, durationMs: 350 },
619+
]);
620+
});
621+
592622
test('runtime viewport gestures reject inspect-only macOS surfaces', async () => {
593623
for (const surface of ['desktop', 'menubar'] as const) {
594624
const device = createInteractionDevice(selectorSnapshot(), {

src/client-types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,11 @@ export type FlingOptions = ClientCommandBaseOptions & {
595595
durationMs?: number;
596596
};
597597

598+
export type SwipeGestureOptions = ClientCommandBaseOptions & {
599+
preset: 'left' | 'right' | 'left-edge' | 'right-edge';
600+
durationMs?: number;
601+
};
602+
598603
export type FocusOptions = ClientCommandBaseOptions & {
599604
x: number;
600605
y: number;
@@ -763,6 +768,11 @@ export type PermissionTarget =
763768
| 'input-monitoring';
764769

765770
export type SettingsUpdateOptions =
771+
| (ClientCommandBaseOptions & {
772+
setting: 'clear-app-state';
773+
state: 'clear';
774+
app?: string;
775+
})
766776
| (ClientCommandBaseOptions & {
767777
setting: 'wifi' | 'airplane' | 'location';
768778
state: 'on' | 'off';
@@ -915,6 +925,7 @@ export type AgentDeviceClient = {
915925
swipe: (options: SwipeOptions) => Promise<CommandRequestResult>;
916926
pan: (options: PanOptions) => Promise<CommandRequestResult>;
917927
fling: (options: FlingOptions) => Promise<CommandRequestResult>;
928+
swipeGesture: (options: SwipeGestureOptions) => Promise<CommandRequestResult>;
918929
focus: (options: FocusOptions) => Promise<CommandRequestResult>;
919930
type: (options: TypeTextOptions) => Promise<CommandRequestResult>;
920931
fill: (options: FillOptions) => Promise<CommandRequestResult>;

src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ export function createAgentDeviceClient(
270270
swipe: async (options) => await executeCommand('swipe', options),
271271
pan: async (options) => await executeCommand('gesture-pan', options),
272272
fling: async (options) => await executeCommand('gesture-fling', options),
273+
swipeGesture: async (options) => await executeCommand('gesture-swipe', options),
273274
focus: async (options) => await executeCommand('focus', options),
274275
type: async (options) => await executeCommand('type', options),
275276
fill: async (options) => await executeCommand('fill', options),

src/command-catalog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const LOCAL_CLI_COMMANDS = {
6666
session: 'session',
6767
} as const;
6868

69-
const GESTURE_SUBCOMMANDS = ['pan', 'fling', 'pinch', 'rotate', 'transform'] as const;
69+
const GESTURE_SUBCOMMANDS = ['pan', 'fling', 'swipe', 'pinch', 'rotate', 'transform'] as const;
7070
export const GESTURE_SUBCOMMAND_ERROR = `gesture requires one of: ${GESTURE_SUBCOMMANDS.join(', ')}`;
7171

7272
export type PublicCommandName = (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS];

src/commands/cli-grammar/capture.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,17 @@ function readSettingsOptionsFromPositionals(
231231
mode: readPermissionMode(positionals[3]),
232232
};
233233
}
234+
if (setting === 'clear-app-state') {
235+
const app = state === 'clear' ? positionals[2] : state;
236+
return { ...base, setting, state: 'clear', app };
237+
}
234238
throw new AppError('INVALID_ARGS', 'Invalid settings arguments.');
235239
}
236240

237241
function settingsPositionals(input: SettingsUpdateOptions): string[] {
242+
if (input.setting === 'clear-app-state') {
243+
return [input.setting, ...optionalString(input.app)];
244+
}
238245
if (input.setting === 'location' && input.state === 'set') {
239246
return [input.setting, input.state, String(input.latitude), String(input.longitude)];
240247
}

src/commands/cli-grammar/gesture.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const gestureDaemonWriters = {
2121
'gesture-fling': direct(PUBLIC_COMMANDS.gesture, (input) =>
2222
flingPositionals(input as FlingOptions),
2323
),
24+
'gesture-swipe': direct(PUBLIC_COMMANDS.gesture, swipePresetPositionals),
2425
'gesture-pinch': direct(PUBLIC_COMMANDS.gesture, pinchPositionals),
2526
'gesture-rotate': direct(PUBLIC_COMMANDS.gesture, (input) =>
2627
rotateGesturePositionals(input as RotateGestureOptions),
@@ -49,6 +50,8 @@ function gesturePositionals(input: CommandInput): string[] {
4950
...optionalNumber(input.distance),
5051
...optionalNumber(input.durationMs),
5152
];
53+
case 'swipe':
54+
return swipePresetPositionals(input);
5255
case 'pinch':
5356
return [
5457
'pinch',
@@ -78,11 +81,19 @@ function gesturePositionals(input: CommandInput): string[] {
7881
default:
7982
throw new AppError(
8083
'INVALID_ARGS',
81-
'gesture requires pan, fling, pinch, rotate, or transform',
84+
'gesture requires pan, fling, swipe, pinch, rotate, or transform',
8285
);
8386
}
8487
}
8588

89+
function swipePresetPositionals(input: CommandInput): string[] {
90+
return [
91+
'swipe',
92+
requiredDaemonString(input.preset, 'gesture swipe requires preset'),
93+
...optionalNumber(input.durationMs),
94+
];
95+
}
96+
8697
function panPositionals(input: CommandInput): string[] {
8798
return [
8899
'pan',
@@ -153,6 +164,13 @@ function gestureInputFromCli(positionals: string[], flags: CliFlags): Record<str
153164
distance: optionalCliNumber(args[3]),
154165
durationMs: optionalCliNumber(args[4]),
155166
};
167+
case 'swipe':
168+
return {
169+
...common,
170+
kind: subcommand,
171+
preset: args[0],
172+
durationMs: optionalCliNumber(args[1]),
173+
};
156174
case 'pinch':
157175
return {
158176
...common,
@@ -187,7 +205,7 @@ function gestureInputFromCli(positionals: string[], flags: CliFlags): Record<str
187205
default:
188206
throw new AppError(
189207
'INVALID_ARGS',
190-
'gesture requires pan, fling, pinch, rotate, or transform',
208+
'gesture requires pan, fling, swipe, pinch, rotate, or transform',
191209
);
192210
}
193211
}

src/commands/interaction-command-contracts.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
LongPressOptions,
1313
RotateGestureOptions,
1414
ScrollOptions,
15+
SwipeGestureOptions,
1516
SwipeOptions,
1617
TransformGestureOptions,
1718
TypeTextOptions,
@@ -35,6 +36,7 @@ import {
3536
type PinchInput,
3637
type PressInput,
3738
type RotateInput,
39+
type SwipeGestureInput,
3840
type TransformInput,
3941
} from './interaction-command-metadata.ts';
4042

@@ -81,6 +83,8 @@ export const interactionCommandDefinitions = [
8183
return await client.interactions.pan(toPanOptions(input));
8284
case 'fling':
8385
return await client.interactions.fling(toFlingOptions(input));
86+
case 'swipe':
87+
return await client.interactions.swipeGesture(toSwipeGestureOptions(input));
8488
case 'pinch':
8589
return await client.interactions.pinch(toPinchOptions(input));
8690
case 'rotate':
@@ -168,6 +172,14 @@ function toFlingOptions(input: FlingInput): FlingOptions {
168172
};
169173
}
170174

175+
function toSwipeGestureOptions(input: SwipeGestureInput): SwipeGestureOptions {
176+
return {
177+
...commonToClientOptions(input),
178+
preset: input.preset,
179+
durationMs: input.durationMs,
180+
};
181+
}
182+
171183
function toPinchOptions(input: PinchInput): PinchOptions {
172184
return {
173185
...commonToClientOptions(input),

0 commit comments

Comments
 (0)