diff --git a/android-snapshot-helper/AndroidManifest.xml b/android-snapshot-helper/AndroidManifest.xml
index 84779e683..feab5c2d7 100644
--- a/android-snapshot-helper/AndroidManifest.xml
+++ b/android-snapshot-helper/AndroidManifest.xml
@@ -2,6 +2,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.callstack.agentdevice.snapshothelper">
+
0) {
+ runSnapshotSession(
+ sessionPort, waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
+ result.putString("ok", "true");
+ result.putString("sessionEnded", "true");
+ finishSafely(0, result);
+ return;
+ }
+ long startedAtMs = System.currentTimeMillis();
+ CaptureResult capture =
+ captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
+ writeOutputFile(outputPath, capture.xml);
+ if (emitChunks) {
+ emitChunks(capture.xml);
+ }
+ result.putString("ok", "true");
+ putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs);
+ finishSafely(0, result);
+ } catch (Throwable error) {
+ result.putString("ok", "false");
+ result.putString("errorType", error.getClass().getName());
+ result.putString(
+ "message",
+ error.getMessage() == null ? error.getClass().getName() : error.getMessage());
+ finishSafely(1, result);
+ }
+ }
+
+ private static void putBaseMetadata(
+ Bundle result,
+ long waitForIdleTimeoutMs,
+ long waitForIdleQuietMs,
+ long timeoutMs,
+ int maxDepth,
+ int maxNodes) {
result.putString("agentDeviceProtocol", PROTOCOL);
result.putString("helperApiVersion", HELPER_API_VERSION);
result.putString("outputFormat", OUTPUT_FORMAT);
@@ -56,31 +105,143 @@ public void onStart() {
result.putString("timeoutMs", Long.toString(timeoutMs));
result.putString("maxDepth", Integer.toString(maxDepth));
result.putString("maxNodes", Integer.toString(maxNodes));
+ }
+
+ private static void putCaptureMetadata(Bundle result, CaptureResult capture, long elapsedMs) {
+ result.putString("rootPresent", Boolean.toString(capture.rootPresent));
+ result.putString("captureMode", capture.captureMode);
+ result.putString("windowCount", Integer.toString(capture.windowCount));
+ result.putString("nodeCount", Integer.toString(capture.nodeCount));
+ result.putString("truncated", Boolean.toString(capture.truncated));
+ result.putString("elapsedMs", Long.toString(elapsedMs));
+ }
+
+ private void runSnapshotSession(
+ int sessionPort,
+ long waitForIdleQuietMs,
+ long waitForIdleTimeoutMs,
+ long timeoutMs,
+ int maxDepth,
+ int maxNodes)
+ throws IOException {
+ try (ServerSocket server =
+ new ServerSocket(sessionPort, 1, InetAddress.getByName("127.0.0.1"))) {
+ Bundle ready = new Bundle();
+ putBaseMetadata(
+ ready, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes);
+ ready.putString("sessionReady", "true");
+ ready.putString("sessionPort", Integer.toString(sessionPort));
+ sendStatus(2, ready);
+
+ while (!Thread.currentThread().isInterrupted()) {
+ try (Socket socket = server.accept()) {
+ String command =
+ new BufferedReader(
+ new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))
+ .readLine();
+ if (command == null) {
+ writeSessionError(socket.getOutputStream(), "", "java.io.EOFException", "empty command");
+ continue;
+ }
+ String[] parts = command.trim().split("\\s+", 2);
+ String action = parts.length > 0 ? parts[0] : "";
+ String requestId = parts.length > 1 ? parts[1] : "";
+ if ("quit".equals(action)) {
+ writeSessionOk(socket.getOutputStream(), requestId);
+ return;
+ }
+ if (!"snapshot".equals(action)) {
+ writeSessionError(
+ socket.getOutputStream(),
+ requestId,
+ "java.lang.IllegalArgumentException",
+ "unknown session command");
+ continue;
+ }
+ writeSessionSnapshot(
+ socket.getOutputStream(),
+ requestId,
+ waitForIdleQuietMs,
+ waitForIdleTimeoutMs,
+ timeoutMs,
+ maxDepth,
+ maxNodes);
+ }
+ }
+ }
+ }
+ private void writeSessionSnapshot(
+ OutputStream output,
+ String requestId,
+ long waitForIdleQuietMs,
+ long waitForIdleTimeoutMs,
+ long timeoutMs,
+ int maxDepth,
+ int maxNodes)
+ throws IOException {
+ Bundle result = new Bundle();
+ putBaseMetadata(result, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes);
+ result.putString("requestId", requestId);
try {
long startedAtMs = System.currentTimeMillis();
CaptureResult capture =
captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
- writeOutputFile(outputPath, capture.xml);
- emitChunks(capture.xml);
result.putString("ok", "true");
- result.putString("rootPresent", Boolean.toString(capture.rootPresent));
- result.putString("captureMode", capture.captureMode);
- result.putString("windowCount", Integer.toString(capture.windowCount));
- result.putString("nodeCount", Integer.toString(capture.nodeCount));
- result.putString("truncated", Boolean.toString(capture.truncated));
- result.putString("elapsedMs", Long.toString(System.currentTimeMillis() - startedAtMs));
- finishSafely(0, result);
+ putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs);
+ result.putString("byteLength", Integer.toString(capture.xml.getBytes(StandardCharsets.UTF_8).length));
+ writeSessionResponse(output, result, capture.xml);
} catch (Throwable error) {
- result.putString("ok", "false");
- result.putString("errorType", error.getClass().getName());
- result.putString(
- "message",
+ writeSessionError(
+ output,
+ requestId,
+ error.getClass().getName(),
error.getMessage() == null ? error.getClass().getName() : error.getMessage());
- finishSafely(1, result);
}
}
+ private static void writeSessionOk(OutputStream output, String requestId) throws IOException {
+ Bundle result = new Bundle();
+ result.putString("agentDeviceProtocol", PROTOCOL);
+ result.putString("helperApiVersion", HELPER_API_VERSION);
+ result.putString("outputFormat", OUTPUT_FORMAT);
+ result.putString("requestId", requestId);
+ result.putString("ok", "true");
+ writeSessionResponse(output, result, "");
+ }
+
+ private static void writeSessionError(
+ OutputStream output, String requestId, String errorType, String message) throws IOException {
+ Bundle result = new Bundle();
+ result.putString("agentDeviceProtocol", PROTOCOL);
+ result.putString("helperApiVersion", HELPER_API_VERSION);
+ result.putString("outputFormat", OUTPUT_FORMAT);
+ result.putString("requestId", requestId);
+ result.putString("ok", "false");
+ result.putString("errorType", errorType);
+ result.putString("message", message);
+ writeSessionResponse(output, result, "");
+ }
+
+ private static void writeSessionResponse(OutputStream output, Bundle result, String body)
+ throws IOException {
+ StringBuilder headers = new StringBuilder();
+ for (String key : result.keySet()) {
+ Object value = result.get(key);
+ if (value != null) {
+ headers.append(key).append('=').append(sanitizeHeaderValue(value.toString())).append('\n');
+ }
+ }
+ headers.append('\n');
+ output.write(headers.toString().getBytes(StandardCharsets.UTF_8));
+ output.write(body.getBytes(StandardCharsets.UTF_8));
+ output.flush();
+ }
+
+ private static String sanitizeHeaderValue(String value) {
+ return value.replace('\r', ' ').replace('\n', ' ');
+ }
+
private static String readStringArgument(Bundle arguments, String key) {
if (arguments == null || !arguments.containsKey(key)) {
return null;
@@ -330,22 +491,34 @@ private static void appendNode(
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
xml.append("");
}
+ private static void appendNonEmptyAttribute(StringBuilder xml, String name, CharSequence value) {
+ if (value == null || value.length() == 0) {
+ return;
+ }
+ appendAttribute(xml, name, value);
+ }
+
+ private static void appendTrueAttribute(StringBuilder xml, String name, boolean value) {
+ if (value) {
+ appendAttribute(xml, name, "true");
+ }
+ }
+
private static void appendAttribute(StringBuilder xml, String name, CharSequence value) {
String stringValue = value == null ? "" : value.toString();
xml.append(' ');
@@ -394,6 +580,12 @@ private static void appendAttribute(StringBuilder xml, String name, CharSequence
xml.append('"');
}
+ private static boolean hasAccessibilityAction(
+ AccessibilityNodeInfo node, AccessibilityAction action) {
+ List actions = node.getActionList();
+ return actions != null && actions.contains(action);
+ }
+
private static void appendEscaped(StringBuilder xml, String value) {
for (int index = 0; index < value.length(); index += 1) {
char character = value.charAt(index);
@@ -459,6 +651,17 @@ private static int readIntArgument(Bundle arguments, String name, int fallback)
}
}
+ private static boolean readBooleanArgument(Bundle arguments, String name, boolean fallback) {
+ if (arguments == null) {
+ return fallback;
+ }
+ String raw = arguments.getString(name);
+ if (raw == null || raw.trim().isEmpty()) {
+ return fallback;
+ }
+ return Boolean.parseBoolean(raw.trim());
+ }
+
private static final class CaptureStats {
int nodeCount;
boolean truncated;
diff --git a/docs/adr/0002-persistent-platform-helper-sessions.md b/docs/adr/0002-persistent-platform-helper-sessions.md
new file mode 100644
index 000000000..acfd28586
--- /dev/null
+++ b/docs/adr/0002-persistent-platform-helper-sessions.md
@@ -0,0 +1,101 @@
+# ADR 0002: Persistent Platform Helper Sessions
+
+## Status
+
+Accepted
+
+## Context
+
+Some platform automation backends are expensive to start but cheap to reuse. iOS already uses a
+long-lived XCTest runner session with an HTTP transport. That model avoids paying `xcodebuild`,
+runner boot, and XCTest readiness costs for every command, while still allowing the daemon to
+invalidate the runner when the device, app, bundle, or runner process changes.
+
+Android snapshot capture initially used a one-shot instrumentation helper. Every snapshot launched
+`adb shell am instrument`, connected `UiAutomation`, captured the tree, emitted XML, and exited.
+Recent Android snapshot optimizations reduced XML size, idle waiting, extra file I/O, and hidden
+content hint work, but a throwaway prototype still showed that process/session startup dominates
+steady-state latency:
+
+- launcher snapshot: one-shot p50 `227ms`, persistent socket p50 `5.8ms`
+- React Navigation playground snapshot: one-shot p50 `265.7ms`, persistent socket p50 `16.5ms`
+
+The same pressure can appear on new platform adapters. HarmonyOS or other device backends may have
+host tools, test runners, accessibility services, or bridge processes with the same shape: expensive
+startup, cheap repeated commands, and a need for strict invalidation.
+
+## Decision
+
+Use persistent platform helper sessions when a backend has high startup cost and a reusable
+automation context.
+
+A helper session is an optimization layer owned by the daemon, not a replacement for command
+correctness. It may keep processes, sockets, runner state, accessibility service flags, or device
+forwards warm. It must still execute each command against fresh platform state unless a separate
+cache contract has explicit invalidation.
+
+The session pattern is:
+
+- start lazily on the first command that benefits from reuse
+- bind the session to a device identity and helper/runner identity
+- communicate through a small validated protocol with request ids and version metadata
+- reuse the session while the identity and protocol remain valid
+- invalidate on device disconnect, helper reinstall/version change, process exit, socket/protocol
+ failure, app/session identity change, or capture options that affect command semantics
+- fall back to the existing one-shot path for the current command when reuse fails
+- make shutdown best effort and make stale sessions disposable
+
+For Android snapshots, productize a persistent helper mode that keeps `UiAutomation` alive and
+serves fresh snapshot requests over an `adb forward` socket. Do not add snapshot result caching as
+part of that first step. The first reliable win is infrastructure reuse, not data reuse. The current
+implementation keeps the existing one-shot instrumentation helper as the fallback for startup,
+socket, protocol, and request failures.
+
+For iOS, keep the XCTest runner session as the reference implementation for lifecycle and
+invalidation behavior. Android does not need to copy iOS internals, but it should reuse the same
+daemon-side ideas: per-device session manager, readiness checks, structured protocol errors,
+fallback/invalidation, and request-scoped observability.
+
+For future platforms such as HarmonyOS, prefer designing adapters around this same helper-session
+contract when their native automation layer is runner-like. Avoid embedding platform-specific
+startup assumptions directly in command handlers.
+
+## Alternatives Considered
+
+- Keep one-shot helpers only: simplest and robust, but Android measurements show it leaves an order
+ of magnitude of steady-state snapshot performance on the table.
+- Cache snapshots in the daemon: faster for repeated reads, but unsafe after mutations, animations,
+ navigation, system dialogs, or app process changes unless a mutation generation contract exists.
+ Cache infrastructure can be added later; it should not be mixed with helper-session reuse.
+- Promote an abstract cross-platform runner immediately: tempting, but premature. iOS XCTest,
+ Android instrumentation, macOS helper, Linux AT-SPI, and future HarmonyOS backends have different
+ startup and transport mechanics. Share the daemon lifecycle contract first, then extract common
+ code only where repetition appears.
+- Replace Android instrumentation with a normal app service: potentially useful, but Android
+ `UiAutomation` access is instrumentation-owned. A persistent instrumentation process keeps the
+ required privilege model while removing repeated process startup.
+
+## Consequences
+
+Persistent helper sessions should be measured before being productized. A prototype or benchmark
+should show meaningful wall-clock improvement on a realistic app state, not just a trivial screen.
+
+Session managers need more lifecycle tests than one-shot helpers: startup, ready protocol, reuse,
+timeout, malformed response, helper version mismatch, device disconnect, install invalidation,
+shutdown, and one-shot fallback.
+
+Observability should report whether a command used a persistent session, started one, reused one,
+invalidated one, or fell back to one-shot. This keeps CI and user bug reports diagnosable when a
+fast path fails.
+
+Persistent sessions should not make direct interactive commands unexpectedly slow. Use short
+connect/request timeouts for the persistent path, then fall back to the existing one-shot timeout
+budget.
+
+The daemon remains the owner of session lifecycle. Platform modules may expose helper-session
+operations, but command handlers should not directly manage long-lived helper processes or raw host
+tool state.
+
+This ADR does not require every backend to implement a persistent session. It defines the preferred
+shape when the backend has the same startup/reuse economics that iOS and Android snapshots now
+demonstrate.
diff --git a/scripts/run-test-app-maestro-suite.mjs b/scripts/run-test-app-maestro-suite.mjs
index 3d9ffcdfb..f0268e00e 100644
--- a/scripts/run-test-app-maestro-suite.mjs
+++ b/scripts/run-test-app-maestro-suite.mjs
@@ -72,9 +72,14 @@ if (options.openTarget) {
runAgentDevice(['wait', 'Agent Device Tester', '30000', '--platform', options.platform, ...options.passthrough]);
}
-for (const flow of flows) {
- runAgentDevice(['replay', flow, '--maestro', '--platform', options.platform, ...options.passthrough]);
-}
+runAgentDevice([
+ 'test',
+ options.flowDir,
+ '--maestro',
+ '--platform',
+ options.platform,
+ ...options.passthrough,
+]);
if (options.close) {
runAgentDevice(['close']);
diff --git a/src/compat/maestro/__tests__/replay-flow.test.ts b/src/compat/maestro/__tests__/replay-flow.test.ts
index 72ab6ebe5..7542d487e 100644
--- a/src/compat/maestro/__tests__/replay-flow.test.ts
+++ b/src/compat/maestro/__tests__/replay-flow.test.ts
@@ -108,7 +108,7 @@ test('parseMaestroReplayFlow maps iOS openLink through the app id when available
);
});
-test('parseMaestroReplayFlow maps Android openLink like Maestro without package binding', () => {
+test('parseMaestroReplayFlow maps Android openLink through the app id when available', () => {
const parsed = parseMaestroReplayFlow(
`appId: com.callstack.agentdevicelab
---
@@ -117,6 +117,20 @@ test('parseMaestroReplayFlow maps Android openLink like Maestro without package
{ platform: 'android' },
);
+ assert.deepEqual(
+ parsed.actions.map((entry) => [entry.command, entry.positionals]),
+ [['open', ['com.callstack.agentdevicelab', 'exp://localhost:8082']]],
+ );
+});
+
+test('parseMaestroReplayFlow maps Android openLink without package binding when appId is absent', () => {
+ const parsed = parseMaestroReplayFlow(
+ `---
+- openLink: exp://localhost:8082
+`,
+ { platform: 'android' },
+ );
+
assert.deepEqual(
parsed.actions.map((entry) => [entry.command, entry.positionals]),
[['open', ['exp://localhost:8082']]],
diff --git a/src/compat/maestro/__tests__/runtime-assertions.test.ts b/src/compat/maestro/__tests__/runtime-assertions.test.ts
new file mode 100644
index 000000000..10e64c32f
--- /dev/null
+++ b/src/compat/maestro/__tests__/runtime-assertions.test.ts
@@ -0,0 +1,127 @@
+import assert from 'node:assert/strict';
+import { afterEach, test, vi } from 'vitest';
+import { invokeMaestroAssertNotVisible, invokeMaestroAssertVisible } from '../runtime-assertions.ts';
+import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+test('invokeMaestroAssertVisible takes a terminal snapshot when the last miss started before the deadline', async () => {
+ vi.spyOn(Date, 'now')
+ .mockReturnValueOnce(0)
+ .mockReturnValueOnce(1000)
+ .mockReturnValueOnce(6500)
+ .mockReturnValueOnce(6500)
+ .mockReturnValueOnce(6600);
+
+ let snapshots = 0;
+ const response = await invokeMaestroAssertVisible({
+ baseReq: {
+ token: 't',
+ session: 's',
+ flags: { platform: 'android' },
+ },
+ positionals: ['label="Details is preloaded!"', '5000'],
+ invoke: async (): Promise => {
+ snapshots += 1;
+ if (snapshots === 1) {
+ return { ok: true, data: { createdAt: 1, nodes: [] } };
+ }
+ return {
+ ok: true,
+ data: {
+ createdAt: 2,
+ nodes: [
+ {
+ index: 1,
+ ref: 'e1',
+ type: 'android.widget.TextView',
+ label: 'Details is preloaded!',
+ rect: { x: 120, y: 900, width: 300, height: 60 },
+ depth: 8,
+ },
+ ],
+ },
+ };
+ },
+ });
+
+ assert.equal(response.ok, true);
+ assert.equal(snapshots, 2);
+ if (response.ok) {
+ assert.ok(response.data);
+ assert.equal(response.data.nodeLabel, 'Details is preloaded!');
+ assert.equal(response.data.waitedMs, 6600);
+ }
+});
+
+test('invokeMaestroAssertNotVisible passes after a slow hidden sample exhausts the timeout', async () => {
+ vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(3500);
+
+ const calls: DaemonRequest[] = [];
+ const response = await invokeMaestroAssertNotVisible({
+ baseReq: {
+ token: 't',
+ session: 's',
+ flags: {},
+ },
+ positionals: ['id="tab-4"'],
+ invoke: async (req): Promise => {
+ calls.push(req);
+ return {
+ ok: true,
+ data: {
+ createdAt: 1,
+ nodes: [],
+ },
+ };
+ },
+ });
+
+ assert.equal(response.ok, true);
+ assert.deepEqual(calls.map((call) => [call.command, call.positionals]), [
+ ['snapshot', []],
+ ]);
+ if (response.ok) {
+ assert.ok(response.data);
+ assert.equal(response.data.stableSamples, 1);
+ assert.equal(response.data.waitedMs, 3500);
+ }
+});
+
+test('invokeMaestroAssertNotVisible ignores matched nodes without visible rects', async () => {
+ vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(3500);
+
+ const response = await invokeMaestroAssertNotVisible({
+ baseReq: {
+ token: 't',
+ session: 's',
+ flags: { platform: 'android' },
+ },
+ positionals: ['label="📌" || text="📌" || id="📌"'],
+ invoke: async (): Promise => ({
+ ok: true,
+ data: {
+ createdAt: 1,
+ nodes: [
+ {
+ index: 1,
+ ref: 'e1',
+ type: 'android.widget.TextView',
+ label: '📌',
+ value: '📌',
+ enabled: true,
+ depth: 21,
+ },
+ ],
+ },
+ }),
+ });
+
+ assert.equal(response.ok, true);
+ if (response.ok) {
+ assert.ok(response.data);
+ assert.equal(response.data.stableSamples, 1);
+ }
+});
diff --git a/src/compat/maestro/__tests__/runtime-geometry.test.ts b/src/compat/maestro/__tests__/runtime-geometry.test.ts
index 1af6a62ab..3be3cce8c 100644
--- a/src/compat/maestro/__tests__/runtime-geometry.test.ts
+++ b/src/compat/maestro/__tests__/runtime-geometry.test.ts
@@ -19,3 +19,22 @@ test('pointForMaestroTapOnTarget biases large scroll-area text containers toward
expect(point).toEqual({ x: 84, y: 141 });
});
+
+test('pointForMaestroTapOnTarget centers tall Android bottom-tab containers', () => {
+ const point = pointForMaestroTapOnTarget(
+ {
+ node: {
+ index: 40,
+ ref: 'e41',
+ type: 'android.widget.FrameLayout',
+ label: 'Albums',
+ rect: { x: 540, y: 2054, width: 270, height: 220 },
+ },
+ rect: { x: 540, y: 2054, width: 270, height: 220 },
+ frame: { referenceWidth: 1080, referenceHeight: 2340 },
+ },
+ true,
+ );
+
+ expect(point).toEqual({ x: 675, y: 2164 });
+});
diff --git a/src/compat/maestro/__tests__/runtime-interactions.test.ts b/src/compat/maestro/__tests__/runtime-interactions.test.ts
index c7212721e..9602e0734 100644
--- a/src/compat/maestro/__tests__/runtime-interactions.test.ts
+++ b/src/compat/maestro/__tests__/runtime-interactions.test.ts
@@ -1,7 +1,7 @@
import { expect, test } from 'vitest';
import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts';
import type { SnapshotState } from '../../../utils/snapshot.ts';
-import { invokeMaestroTapOn } from '../runtime-interactions.ts';
+import { invokeMaestroSwipeScreen, invokeMaestroTapOn } from '../runtime-interactions.ts';
test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', async () => {
const selector =
@@ -34,6 +34,31 @@ test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot',
expect(clicks).toEqual([['86', '89']]);
});
+test('invokeMaestroSwipeScreen uses a conservative Android content-lane directional swipe', async () => {
+ const swipes: string[][] = [];
+ const response = await invokeMaestroSwipeScreen({
+ baseReq: {
+ token: 'test',
+ session: 'pager',
+ flags: { platform: 'android' },
+ },
+ positionals: ['direction', 'left', '300'],
+ invoke: async (req: DaemonRequest): Promise => {
+ if (req.command === 'snapshot') {
+ return { ok: true, data: fullScreenSnapshot(1080, 2340) };
+ }
+ if (req.command === 'swipe') {
+ swipes.push(req.positionals ?? []);
+ return { ok: true, data: {} };
+ }
+ return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
+ },
+ });
+
+ expect(response.ok).toBe(true);
+ expect(swipes).toEqual([['756', '1521', '324', '1521', '300']]);
+});
+
function currentBreadcrumbSnapshot(): SnapshotState {
return {
createdAt: Date.now(),
@@ -62,6 +87,30 @@ function currentBreadcrumbSnapshot(): SnapshotState {
};
}
+function fullScreenSnapshot(width: number, height: number): SnapshotState {
+ return {
+ createdAt: Date.now(),
+ nodes: [
+ {
+ index: 0,
+ ref: 'e1',
+ type: 'Application',
+ label: 'Android Test App',
+ depth: 0,
+ rect: { x: 0, y: 0, width, height },
+ },
+ {
+ index: 1,
+ ref: 'e2',
+ type: 'Window',
+ depth: 1,
+ parentIndex: 0,
+ rect: { x: 0, y: 0, width, height },
+ },
+ ],
+ };
+}
+
function appNode(): SnapshotState['nodes'][number] {
return {
index: 0,
diff --git a/src/compat/maestro/__tests__/runtime-targets.test.ts b/src/compat/maestro/__tests__/runtime-targets.test.ts
index d8279f795..d5d198f4a 100644
--- a/src/compat/maestro/__tests__/runtime-targets.test.ts
+++ b/src/compat/maestro/__tests__/runtime-targets.test.ts
@@ -59,6 +59,42 @@ test('resolveMaestroNodeFromSnapshot blocks taps on app content behind React Nat
});
});
+test('resolveVisibleMaestroNodeFromSnapshot does not block content behind collapsed React Native warnings', () => {
+ const snapshot: SnapshotState = {
+ createdAt: Date.now(),
+ nodes: [
+ {
+ index: 1,
+ ref: 'e1',
+ type: 'android.widget.TextView',
+ label: 'Morning Favorites',
+ rect: { x: 24, y: 420, width: 320, height: 54 },
+ depth: 8,
+ },
+ {
+ index: 2,
+ ref: 'e2',
+ type: 'android.view.ViewGroup',
+ label: 'Open debugger to view warnings',
+ rect: { x: 0, y: 2190, width: 1080, height: 96 },
+ depth: 6,
+ },
+ ],
+ };
+
+ const appContent = resolveVisibleMaestroNodeFromSnapshot(
+ snapshot,
+ 'label="Morning Favorites" || text="Morning Favorites" || id="Morning Favorites"',
+ 'android',
+ { referenceWidth: 1080, referenceHeight: 2340 },
+ );
+
+ expect(appContent).toMatchObject({
+ ok: true,
+ node: expect.objectContaining({ label: 'Morning Favorites' }),
+ });
+});
+
test('resolveMaestroNodeFromSnapshot prefers foreground duplicate matches', () => {
const snapshot: SnapshotState = {
createdAt: Date.now(),
@@ -161,6 +197,178 @@ test('resolveVisibleMaestroNodeFromSnapshot requires visible text matches to be
});
});
+test('resolveVisibleMaestroNodeFromSnapshot ignores Android rectless hidden navigation labels', () => {
+ const snapshot: SnapshotState = {
+ createdAt: Date.now(),
+ nodes: [
+ {
+ index: 1,
+ ref: 'e1',
+ type: 'android.view.ViewGroup',
+ label: '',
+ rect: { x: 0, y: 0, width: 1080, height: 2340 },
+ depth: 1,
+ },
+ {
+ index: 2,
+ ref: 'e2',
+ type: 'android.widget.Button',
+ label: 'Chat',
+ enabled: true,
+ hittable: true,
+ depth: 2,
+ parentIndex: 1,
+ },
+ {
+ index: 3,
+ ref: 'e3',
+ type: 'android.widget.TextView',
+ label: 'Chat',
+ value: 'Chat',
+ depth: 3,
+ parentIndex: 2,
+ },
+ ],
+ };
+
+ const target = resolveVisibleMaestroNodeFromSnapshot(
+ snapshot,
+ 'label="Chat" || text="Chat" || id="Chat"',
+ 'android',
+ { referenceWidth: 1080, referenceHeight: 2340 },
+ );
+
+ expect(target).toMatchObject({
+ ok: false,
+ message: expect.stringContaining('none were visible'),
+ });
+});
+
+test('resolveMaestroNodeFromSnapshot prefers concrete Android tab rect over hidden drawer label', () => {
+ const snapshot: SnapshotState = {
+ createdAt: Date.now(),
+ nodes: [
+ {
+ index: 1,
+ ref: 'e1',
+ type: 'android.view.ViewGroup',
+ label: '',
+ rect: { x: 0, y: 0, width: 1080, height: 2340 },
+ depth: 1,
+ },
+ {
+ index: 2,
+ ref: 'e2',
+ type: 'android.widget.FrameLayout',
+ label: 'Albums',
+ rect: { x: 540, y: 2054, width: 270, height: 220 },
+ enabled: true,
+ hittable: true,
+ depth: 16,
+ parentIndex: 1,
+ },
+ {
+ index: 3,
+ ref: 'e3',
+ type: 'android.view.ViewGroup',
+ label: '',
+ rect: { x: 0, y: 0, width: 816, height: 2340 },
+ depth: 1,
+ },
+ {
+ index: 4,
+ ref: 'e4',
+ type: 'android.widget.Button',
+ label: '\udb80\udeea, Albums',
+ enabled: true,
+ hittable: true,
+ depth: 18,
+ parentIndex: 3,
+ },
+ {
+ index: 5,
+ ref: 'e5',
+ type: 'android.widget.TextView',
+ label: 'Albums',
+ value: 'Albums',
+ enabled: true,
+ hittable: false,
+ depth: 19,
+ parentIndex: 4,
+ },
+ ],
+ };
+
+ const target = resolveMaestroNodeFromSnapshot(
+ snapshot,
+ 'label="Albums" || text="Albums" || id="Albums"',
+ {},
+ 'android',
+ { referenceWidth: 1080, referenceHeight: 2340 },
+ { promoteTapTarget: true },
+ );
+
+ expect(target).toMatchObject({
+ ok: true,
+ node: expect.objectContaining({ index: 2 }),
+ rect: { x: 540, y: 2054, width: 270, height: 220 },
+ });
+});
+
+test('resolveMaestroNodeFromSnapshot prefers exact Android tab label over normalized header icon text', () => {
+ const snapshot: SnapshotState = {
+ createdAt: Date.now(),
+ nodes: [
+ {
+ index: 1,
+ ref: 'e1',
+ type: 'android.widget.FrameLayout',
+ label: 'Search',
+ rect: { x: 810, y: 2054, width: 270, height: 132 },
+ enabled: true,
+ hittable: true,
+ depth: 16,
+ },
+ {
+ index: 2,
+ ref: 'e2',
+ type: 'android.widget.Button',
+ label: 'search',
+ rect: { x: 673, y: 165, width: 132, height: 132 },
+ enabled: true,
+ hittable: true,
+ depth: 22,
+ },
+ {
+ index: 3,
+ ref: 'e3',
+ type: 'android.widget.TextView',
+ label: 'search',
+ value: 'search',
+ rect: { x: 706, y: 198, width: 66, height: 66 },
+ enabled: true,
+ depth: 23,
+ parentIndex: 2,
+ },
+ ],
+ };
+
+ const target = resolveMaestroNodeFromSnapshot(
+ snapshot,
+ 'label="Search" || text="Search" || id="Search"',
+ {},
+ 'android',
+ { referenceWidth: 1080, referenceHeight: 2340 },
+ { promoteTapTarget: true },
+ );
+
+ expect(target).toMatchObject({
+ ok: true,
+ node: expect.objectContaining({ index: 1 }),
+ rect: { x: 810, y: 2054, width: 270, height: 132 },
+ });
+});
+
test('resolveMaestroNodeFromSnapshot infers missing selected tab slot from tab-strip children', () => {
const snapshot: SnapshotState = {
createdAt: Date.now(),
@@ -250,6 +458,131 @@ test('resolveMaestroNodeFromSnapshot keeps concrete child matches over tab-strip
});
});
+test('resolveMaestroNodeFromSnapshot prefers localized breadcrumb label over broad containers', () => {
+ const snapshot: SnapshotState = {
+ createdAt: Date.now(),
+ nodes: [
+ {
+ index: 1,
+ ref: 'e1',
+ type: 'Other',
+ label: 'Article by Gandalf',
+ rect: { x: 0, y: 0, width: 402, height: 116.66666412353516 },
+ depth: 12,
+ },
+ {
+ index: 2,
+ ref: 'e2',
+ type: 'ScrollView',
+ label: 'Article by Gandalf',
+ rect: { x: 0, y: 0, width: 402, height: 116.66666666666666 },
+ depth: 13,
+ parentIndex: 1,
+ },
+ {
+ index: 3,
+ ref: 'e3',
+ type: 'Other',
+ label: 'Article by Gandalf',
+ rect: { x: 0, y: 0, width: 232.3333282470703, height: 116.33333587646484 },
+ depth: 14,
+ parentIndex: 2,
+ },
+ {
+ index: 4,
+ ref: 'e4',
+ type: 'Other',
+ label: 'Article by Gandalf',
+ rect: { x: 0, y: 0, width: 232.3333282470703, height: 116.33333587646484 },
+ depth: 15,
+ parentIndex: 3,
+ },
+ {
+ index: 5,
+ ref: 'e5',
+ type: 'Other',
+ label: 'Article by Gandalf',
+ rect: { x: 8, y: 65.33333587646484, width: 155, height: 48 },
+ depth: 16,
+ parentIndex: 4,
+ },
+ {
+ index: 6,
+ ref: 'e6',
+ type: 'Other',
+ label: 'Feed',
+ rect: { x: 170.3333282470703, y: 65.33333587646484, width: 54, height: 48 },
+ depth: 16,
+ parentIndex: 4,
+ },
+ ],
+ };
+
+ const target = resolveMaestroNodeFromSnapshot(
+ snapshot,
+ 'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"',
+ {},
+ 'ios',
+ { referenceWidth: 402, referenceHeight: 874 },
+ { promoteTapTarget: true },
+ );
+
+ expect(target).toMatchObject({
+ ok: true,
+ node: expect.objectContaining({ index: 5 }),
+ rect: { x: 8, y: 65.33333587646484, width: 155, height: 48 },
+ });
+});
+
+test('resolveMaestroNodeFromSnapshot infers leading breadcrumb slot when selected child is omitted', () => {
+ const snapshot: SnapshotState = {
+ createdAt: Date.now(),
+ nodes: [
+ {
+ index: 1,
+ ref: 'e1',
+ type: 'ScrollView',
+ label: 'Article by Gandalf',
+ rect: { x: 0, y: 58.33333333333333, width: 402, height: 58.33333333333333 },
+ depth: 4,
+ },
+ {
+ index: 2,
+ ref: 'e2',
+ type: 'Other',
+ label: 'Feed',
+ rect: { x: 170.3333282470703, y: 65.33333587646484, width: 54, height: 48 },
+ depth: 5,
+ parentIndex: 1,
+ },
+ {
+ index: 3,
+ ref: 'e3',
+ type: 'Other',
+ label: 'Albums',
+ rect: { x: 231.6666717529297, y: 65.33333587646484, width: 75, height: 48 },
+ depth: 5,
+ parentIndex: 1,
+ },
+ ],
+ };
+
+ const target = resolveMaestroNodeFromSnapshot(
+ snapshot,
+ 'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"',
+ {},
+ 'ios',
+ { referenceWidth: 402, referenceHeight: 874 },
+ { promoteTapTarget: true },
+ );
+
+ expect(target).toMatchObject({
+ ok: true,
+ node: expect.objectContaining({ index: 1 }),
+ rect: { x: 0, y: 58.33333333333333, width: 168, height: 58.33333333333333 },
+ });
+});
+
function makeReactNativeOverlaySnapshot(): SnapshotState {
return {
createdAt: Date.now(),
diff --git a/src/compat/maestro/__tests__/support-matrix.test.ts b/src/compat/maestro/__tests__/support-matrix.test.ts
new file mode 100644
index 000000000..d11654b9d
--- /dev/null
+++ b/src/compat/maestro/__tests__/support-matrix.test.ts
@@ -0,0 +1,28 @@
+import fs from 'node:fs';
+import { expect, test } from 'vitest';
+import { getFlagDefinitions } from '../../../utils/cli-flags.ts';
+import {
+ MAESTRO_COMPAT_SUPPORTED_CAPABILITIES,
+ MAESTRO_COMPAT_TRACKER_URL,
+ MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES,
+ formatMaestroCapabilityList,
+} from '../support-matrix.ts';
+
+test('Maestro CLI help uses the shared compatibility support matrix', () => {
+ const flag = getFlagDefinitions().find((definition) => definition.key === 'replayMaestro');
+ expect(flag?.usageDescription).toContain(
+ `Supported subset: ${formatMaestroCapabilityList(MAESTRO_COMPAT_SUPPORTED_CAPABILITIES)}.`,
+ );
+ expect(flag?.usageDescription).toContain(MAESTRO_COMPAT_TRACKER_URL);
+});
+
+test('Maestro replay docs stay in sync with the compatibility support matrix', () => {
+ const docs = fs.readFileSync('website/docs/docs/replay-e2e.md', 'utf8');
+ const plainDocs = docs.replace(/`/g, '');
+ for (const capability of MAESTRO_COMPAT_SUPPORTED_CAPABILITIES) {
+ expect(plainDocs).toContain(capability);
+ }
+ for (const capability of MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES) {
+ expect(plainDocs).toContain(capability);
+ }
+});
diff --git a/src/compat/maestro/command-mapper.ts b/src/compat/maestro/command-mapper.ts
index 36f294b40..83c8b642f 100644
--- a/src/compat/maestro/command-mapper.ts
+++ b/src/compat/maestro/command-mapper.ts
@@ -154,7 +154,7 @@ function convertOpenLink(
): SessionAction {
const rawLink = readOpenLink(value, name);
const url = resolveMaestroString(rawLink, context);
- if (context.platform === 'ios' && config.appId) {
+ if ((context.platform === 'ios' || context.platform === 'android') && config.appId) {
return action('open', [resolveMaestroString(requireAppId(config, name), context), url]);
}
return action('open', [url]);
diff --git a/src/compat/maestro/runtime-assertions.ts b/src/compat/maestro/runtime-assertions.ts
index b4645e01b..70dd80bdb 100644
--- a/src/compat/maestro/runtime-assertions.ts
+++ b/src/compat/maestro/runtime-assertions.ts
@@ -29,63 +29,113 @@ export async function invokeMaestroAssertVisible(params: {
invoke: MaestroRuntimeInvoke;
scope?: ReplayVarScope;
}): Promise {
- const [selector, timeoutValue = '5000'] = params.positionals;
- if (!selector) {
- return errorResponse('INVALID_ARGS', 'assertVisible requires a selector.');
- }
- const timeoutMs = Number(timeoutValue);
- if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
- return errorResponse('INVALID_ARGS', 'assertVisible timeout must be a non-negative number.');
- }
+ const args = readAssertVisibleArgs(params.positionals);
+ if (!args.ok) return args.response;
const startedAt = Date.now();
- const deadlineMs = timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs;
+ const deadlineMs = args.timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs;
let lastResponse: DaemonResponse | undefined;
- do {
- const response = await captureMaestroRawSnapshot(params);
- lastResponse = response;
- if (response.ok) {
- const snapshot = readSnapshotState(response.data);
- if (!snapshot) {
- return errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for assertVisible.');
- }
- const target = resolveVisibleMaestroNodeFromSnapshot(
- snapshot,
- selector,
- readMaestroSelectorPlatform(params.baseReq.flags),
- getSnapshotReferenceFrame(snapshot),
- );
- if (target.ok) {
- return {
- ok: true,
- data: {
- selector,
- matches: target.matches,
- nodeIndex: target.node.index,
- nodeType: target.node.type,
- nodeLabel: target.node.label,
- nodeIdentifier: target.node.identifier,
- rect: target.rect,
- waitedMs: Date.now() - startedAt,
- },
- };
+ let capturedAfterDeadline = false;
+ while (true) {
+ const captureStartedAt = Date.now();
+ const attempt = await readAssertVisibleAttempt(params, args.selector, startedAt);
+ if (attempt.done) return attempt.response;
+ lastResponse = attempt.response;
+
+ const elapsedMs = Date.now() - startedAt;
+ if (elapsedMs >= deadlineMs) {
+ if (shouldCaptureOnceAfterDeadline(capturedAfterDeadline, captureStartedAt, startedAt, deadlineMs)) {
+ capturedAfterDeadline = true;
+ continue;
}
- lastResponse = errorResponse('COMMAND_FAILED', target.message, { selector });
+ break;
}
-
- if (Date.now() - startedAt >= deadlineMs) break;
await sleep(MAESTRO_ASSERTION_POLICY.assertVisiblePollMs);
- } while (Date.now() - startedAt <= deadlineMs);
+ }
return (
lastResponse ??
- errorResponse('COMMAND_FAILED', `Expected visible but did not match: ${selector}`, {
- selector,
- timeoutMs,
+ errorResponse('COMMAND_FAILED', `Expected visible but did not match: ${args.selector}`, {
+ selector: args.selector,
+ timeoutMs: args.timeoutMs,
})
);
}
+function readAssertVisibleArgs(
+ positionals: string[],
+):
+ | { ok: true; selector: string; timeoutMs: number }
+ | { ok: false; response: DaemonResponse } {
+ const [selector, timeoutValue = '5000'] = positionals;
+ if (!selector) {
+ return { ok: false, response: errorResponse('INVALID_ARGS', 'assertVisible requires a selector.') };
+ }
+ const timeoutMs = Number(timeoutValue);
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
+ return {
+ ok: false,
+ response: errorResponse('INVALID_ARGS', 'assertVisible timeout must be a non-negative number.'),
+ };
+ }
+ return { ok: true, selector, timeoutMs };
+}
+
+async function readAssertVisibleAttempt(
+ params: {
+ baseReq: ReplayBaseRequest;
+ positionals: string[];
+ invoke: MaestroRuntimeInvoke;
+ scope?: ReplayVarScope;
+ },
+ selector: string,
+ startedAt: number,
+): Promise<{ done: true; response: DaemonResponse } | { done: false; response: DaemonResponse }> {
+ const response = await captureMaestroRawSnapshot(params);
+ if (!response.ok) return { done: false, response };
+ const snapshot = readSnapshotState(response.data);
+ if (!snapshot) {
+ return {
+ done: true,
+ response: errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for assertVisible.'),
+ };
+ }
+ const target = resolveVisibleMaestroNodeFromSnapshot(
+ snapshot,
+ selector,
+ readMaestroSelectorPlatform(params.baseReq.flags),
+ getSnapshotReferenceFrame(snapshot),
+ );
+ if (!target.ok) {
+ return { done: false, response: errorResponse('COMMAND_FAILED', target.message, { selector }) };
+ }
+ return {
+ done: true,
+ response: {
+ ok: true,
+ data: {
+ selector,
+ matches: target.matches,
+ nodeIndex: target.node.index,
+ nodeType: target.node.type,
+ nodeLabel: target.node.label,
+ nodeIdentifier: target.node.identifier,
+ rect: target.rect,
+ waitedMs: Date.now() - startedAt,
+ },
+ },
+ };
+}
+
+function shouldCaptureOnceAfterDeadline(
+ capturedAfterDeadline: boolean,
+ captureStartedAt: number,
+ startedAt: number,
+ deadlineMs: number,
+): boolean {
+ return !capturedAfterDeadline && captureStartedAt - startedAt < deadlineMs;
+}
+
export async function invokeMaestroAssertNotVisible(params: {
baseReq: ReplayBaseRequest;
positionals: string[];
@@ -99,30 +149,30 @@ export async function invokeMaestroAssertNotVisible(params: {
let hiddenSamples = 0;
let lastVisibleResponse: DaemonResponse | undefined;
while (Date.now() - startedAt <= MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs) {
- const response = await params.invoke({
- ...params.baseReq,
- command: 'is',
- positionals: ['visible', selector],
- flags: { ...params.baseReq.flags, noRecord: true },
- });
- if (response.ok) {
+ const attempt = await readAssertNotVisibleAttempt(params, selector);
+ if (attempt.visible) {
hiddenSamples = 0;
- lastVisibleResponse = response;
- } else if (isMaestroVisibilityMiss(response)) {
+ lastVisibleResponse = attempt.response;
+ } else if (attempt.hidden) {
hiddenSamples += 1;
- if (hiddenSamples >= 2) {
+ const waitedMs = Date.now() - startedAt;
+ if (
+ hiddenSamples >= 2 ||
+ waitedMs >= MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs
+ ) {
return {
ok: true,
data: {
pass: true,
selector,
stableSamples: hiddenSamples,
+ waitedMs,
timeoutMs: MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs,
},
};
}
} else {
- return response;
+ return attempt.response;
}
await sleep(MAESTRO_ASSERTION_POLICY.assertNotVisiblePollMs);
}
@@ -133,6 +183,59 @@ export async function invokeMaestroAssertNotVisible(params: {
});
}
+async function readAssertNotVisibleAttempt(
+ params: {
+ baseReq: ReplayBaseRequest;
+ positionals: string[];
+ invoke: MaestroRuntimeInvoke;
+ },
+ selector: string,
+): Promise<
+ | { visible: true; hidden: false; response: DaemonResponse }
+ | { visible: false; hidden: true; response: DaemonResponse }
+ | { visible: false; hidden: false; response: DaemonResponse }
+> {
+ const response = await captureMaestroRawSnapshot(params);
+ if (!response.ok) return { visible: false, hidden: false, response };
+ const snapshot = readSnapshotState(response.data);
+ if (!snapshot) {
+ return {
+ visible: false,
+ hidden: false,
+ response: errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for assertNotVisible.'),
+ };
+ }
+ const target = resolveVisibleMaestroNodeFromSnapshot(
+ snapshot,
+ selector,
+ readMaestroSelectorPlatform(params.baseReq.flags),
+ getSnapshotReferenceFrame(snapshot),
+ );
+ if (!target.ok) {
+ return {
+ visible: false,
+ hidden: true,
+ response: errorResponse('COMMAND_FAILED', target.message, { selector }),
+ };
+ }
+ return {
+ visible: true,
+ hidden: false,
+ response: {
+ ok: true,
+ data: {
+ selector,
+ matches: target.matches,
+ nodeIndex: target.node.index,
+ nodeType: target.node.type,
+ nodeLabel: target.node.label,
+ nodeIdentifier: target.node.identifier,
+ rect: target.rect,
+ },
+ },
+ };
+}
+
export async function invokeMaestroWaitForAnimationToEnd(params: {
baseReq: ReplayBaseRequest;
positionals: string[];
@@ -160,14 +263,6 @@ export async function invokeMaestroWaitForAnimationToEnd(params: {
: { ok: true, data: { stable: false, timeoutMs } };
}
-function isMaestroVisibilityMiss(response: Extract): boolean {
- const details = response.error.details;
- return (
- details?.command === 'is' &&
- (details.reason === 'selector_not_found' || details.reason === 'predicate_failed')
- );
-}
-
function readAnimationPollResult(
response: DaemonResponse,
previousSignature: string | undefined,
diff --git a/src/compat/maestro/runtime-flow.ts b/src/compat/maestro/runtime-flow.ts
index 4feb4f962..256b096d9 100644
--- a/src/compat/maestro/runtime-flow.ts
+++ b/src/compat/maestro/runtime-flow.ts
@@ -1,8 +1,12 @@
import { type CommandFlags } from '../../core/dispatch.ts';
-import type { DaemonRequest, DaemonResponse, SessionAction } from '../../daemon/types.ts';
+import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts';
import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts';
import {
- batchStepToSessionAction,
+ batchStepsToSessionActions,
+ invokeReplayActionBlock,
+ invokeReplayRetryBlock,
+} from '../../replay/control-flow-runtime.ts';
+import {
captureMaestroRawSnapshot,
errorResponse,
readSnapshotState,
@@ -53,16 +57,13 @@ export async function invokeMaestroRetry(params: {
return errorResponse('INVALID_ARGS', 'retry.maxRetries must be a non-negative integer.');
}
- const steps = (params.batchSteps ?? []).map(batchStepToSessionAction);
- let lastResponse: DaemonResponse | undefined;
- for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
- const response = await invokeMaestroRetryAttempt(params, steps, attempt);
- if (response.ok) {
- return { ok: true, data: { attempts: attempt + 1, retried: attempt > 0 } };
- }
- lastResponse = response;
- }
- return lastResponse ?? errorResponse('COMMAND_FAILED', 'retry commands failed.');
+ return await invokeReplayRetryBlock({
+ actions: batchStepsToSessionActions(params.batchSteps),
+ maxRetries,
+ line: params.line,
+ step: params.step,
+ invokeReplayAction: params.invokeReplayAction,
+ });
}
function readMaestroRunFlowWhenCondition(positionals: string[]): MaestroRunFlowWhenCondition {
@@ -118,40 +119,16 @@ async function invokeMaestroRunFlowWhenSteps(
},
condition: Extract,
): Promise {
- const steps = (params.batchSteps ?? []).map(batchStepToSessionAction);
- for (const [index, action] of steps.entries()) {
- // Preserve stable parent-step ordering for nested runtime commands while
- // keeping the substep distinguishable in traces.
- const response = await params.invokeReplayAction({
- action,
- line: params.line,
- step: params.step + index / 1000,
- });
- if (!response.ok) return response;
- }
+ const response = await invokeReplayActionBlock({
+ actions: batchStepsToSessionActions(params.batchSteps),
+ line: params.line,
+ step: params.step,
+ invokeReplayAction: params.invokeReplayAction,
+ });
+ if (!response.ok) return response;
return {
ok: true,
- data: { ran: steps.length, condition: condition.mode, selector: condition.selector },
+ data: { ran: response.data?.ran, condition: condition.mode, selector: condition.selector },
};
}
-
-async function invokeMaestroRetryAttempt(
- params: {
- line: number;
- step: number;
- invokeReplayAction: MaestroReplayInvoker;
- },
- steps: SessionAction[],
- attempt: number,
-): Promise {
- for (const [index, action] of steps.entries()) {
- const response = await params.invokeReplayAction({
- action,
- line: params.line,
- step: params.step + attempt + index / 1000,
- });
- if (!response.ok) return response;
- }
- return { ok: true, data: { ran: steps.length } };
-}
diff --git a/src/compat/maestro/runtime-geometry.ts b/src/compat/maestro/runtime-geometry.ts
index e4cb6d77b..0a3027ea3 100644
--- a/src/compat/maestro/runtime-geometry.ts
+++ b/src/compat/maestro/runtime-geometry.ts
@@ -1,4 +1,5 @@
import type { Rect, SnapshotNode } from '../../utils/snapshot.ts';
+import { interiorCoordinate, pointInsideRect } from '../../utils/rect-center.ts';
import { normalizeType } from '../../utils/snapshot-processing.ts';
import type { MaestroSnapshotTarget } from './runtime-targets.ts';
@@ -12,6 +13,7 @@ const MAESTRO_GEOMETRY_POLICY = {
largeTextContainerBias: {
minWidth: 120,
minHeight: 70,
+ maxHeight: 200,
width: 168,
height: 48,
},
@@ -96,13 +98,6 @@ function clampCoordinate(value: number, min: number, max: number): number {
return Math.round(Math.min(max, Math.max(min, value)));
}
-function pointInsideRect(rect: Rect): { x: number; y: number } {
- return {
- x: interiorCoordinate(rect.x, rect.width),
- y: interiorCoordinate(rect.y, rect.height),
- };
-}
-
function shouldBiasMaestroVisibleTextTap(
node: SnapshotNode,
isVisibleTextSelector: boolean,
@@ -115,15 +110,6 @@ function shouldBiasMaestroVisibleTextTap(
const type = normalizeType(node.type ?? '');
const scrollableTextContainer = type === 'scrollview' || type === 'scroll-area';
if (rect.height < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minHeight) return false;
+ if (rect.height > MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.maxHeight) return false;
return type === 'cell' || type === 'other' || scrollableTextContainer;
}
-
-function interiorCoordinate(origin: number, size: number): number {
- // Maestro flows often expose hidden E2E controls as 1x1 views at the screen
- // edge. Preserve zero-origin taps for those controls instead of nudging them
- // outside their tiny rect by applying normal center/bounds clamping.
- if (size <= 1) return Math.floor(origin);
- const min = Math.ceil(origin);
- const max = Math.floor(origin + size - 1);
- return clampCoordinate(origin + size / 2, min, max);
-}
diff --git a/src/compat/maestro/runtime-interactions.ts b/src/compat/maestro/runtime-interactions.ts
index 0592cdf7f..a68e02130 100644
--- a/src/compat/maestro/runtime-interactions.ts
+++ b/src/compat/maestro/runtime-interactions.ts
@@ -270,12 +270,14 @@ function resolveDirectionalScreenSwipe(
case 'down':
return { ok: true, start: point(50, 20), end: point(50, 80), durationMs };
case 'left': {
- const yPercent = androidHorizontalContentSwipeY(platform, 80, 50, 20, 50);
- return { ok: true, start: point(80, yPercent), end: point(20, yPercent), durationMs };
+ const [startX, endX] = androidHorizontalDirectionalSwipeX(platform, 80, 20);
+ const yPercent = androidHorizontalContentSwipeY(platform, startX, 50, endX, 50);
+ return { ok: true, start: point(startX, yPercent), end: point(endX, yPercent), durationMs };
}
case 'right': {
- const yPercent = androidHorizontalContentSwipeY(platform, 20, 50, 80, 50);
- return { ok: true, start: point(20, yPercent), end: point(80, yPercent), durationMs };
+ const [startX, endX] = androidHorizontalDirectionalSwipeX(platform, 20, 80);
+ const yPercent = androidHorizontalContentSwipeY(platform, startX, 50, endX, 50);
+ return { ok: true, start: point(startX, yPercent), end: point(endX, yPercent), durationMs };
}
default:
return {
@@ -288,6 +290,15 @@ function resolveDirectionalScreenSwipe(
}
}
+function androidHorizontalDirectionalSwipeX(
+ platform: string,
+ startX: number,
+ endX: number,
+): [number, number] {
+ if (platform !== 'android') return [startX, endX];
+ return startX < endX ? [30, 70] : [70, 30];
+}
+
function resolvePercentScreenSwipe(
args: string[],
frame: { referenceWidth: number; referenceHeight: number },
@@ -320,7 +331,7 @@ function androidHorizontalContentSwipeY(
): number {
if (platform !== 'android') return y2;
if (y1 !== y2 || y1 !== 50) return y2;
- if (Math.abs(x2 - x1) < 50) return y2;
+ if (Math.abs(x2 - x1) < 30) return y2;
// Maestro's Android driver treats 50% horizontal swipes as content swipes.
// Raw `adb input swipe` at the physical screen midpoint can land above
// horizontally paged content in React Native layouts, so use a lower content
diff --git a/src/compat/maestro/runtime-support.ts b/src/compat/maestro/runtime-support.ts
index fc7d6aa90..36b1cbafb 100644
--- a/src/compat/maestro/runtime-support.ts
+++ b/src/compat/maestro/runtime-support.ts
@@ -1,4 +1,3 @@
-import type { CommandFlags } from '../../core/dispatch.ts';
import {
getSnapshotReferenceFrame,
type TouchReferenceFrame,
@@ -74,18 +73,3 @@ function rememberMaestroReferenceFrame(scope: ReplayVarScope, data: unknown): vo
const frame = getSnapshotReferenceFrame(snapshot);
if (frame) maestroReferenceFrameCache.set(scope, frame);
}
-
-export function batchStepToSessionAction(
- step: NonNullable[number],
-): SessionAction {
- const action: SessionAction = {
- ts: Date.now(),
- command: step.command,
- positionals: step.positionals ?? [],
- flags: step.flags ?? {},
- };
- if (step.runtime && typeof step.runtime === 'object') {
- action.runtime = step.runtime as SessionAction['runtime'];
- }
- return action;
-}
diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts
index 5eb51d6c2..b0a366fa3 100644
--- a/src/compat/maestro/runtime-targets.ts
+++ b/src/compat/maestro/runtime-targets.ts
@@ -184,6 +184,9 @@ function filterReactNativeOverlayBlockedMatches(
if (!overlay.detected) {
return { matches, blockedByReactNativeOverlay: false };
}
+ if (!overlay.redBox) {
+ return { matches, blockedByReactNativeOverlay: false };
+ }
const overlayNodeIndexes = new Set(
[...overlay.dismissNodes, ...overlay.minimizeNodes, ...overlay.collapsedNodes].map(
(node) => node.index,
@@ -352,8 +355,10 @@ function resolveMaestroSnapshotMatchCandidates(
const resolved = matches
.map((node) => resolveMaestroSnapshotMatch(nodes, node, nodeByIndex))
.filter((candidate): candidate is MaestroResolvedSnapshotMatch => Boolean(candidate));
+ const concrete = resolved.filter((candidate) => !candidate.inheritedRect);
+ const candidates = concrete.length > 0 ? concrete : resolved;
if (!visibleTextQuery || index !== undefined) return resolved;
- return preferOnScreenMatches(resolved, frame, requireOnScreen);
+ return preferOnScreenMatches(candidates, frame, requireOnScreen);
}
function resolveMaestroSnapshotMatch(
@@ -373,9 +378,37 @@ function chooseMaestroSnapshotMatch(
promoteTapTarget: boolean,
): MaestroResolvedSnapshotMatch | null {
if (index !== undefined) return candidates[index] ?? null;
- const best = selectBestMaestroSnapshotMatch(candidates, visibleTextQuery);
- if (!promoteTapTarget || !visibleTextQuery || !best) return best;
- return inferMaestroMissingTabSlotMatch(nodes, best, visibleTextQuery) ?? best;
+ const best = selectPreferredMaestroSnapshotMatch(
+ nodes,
+ candidates,
+ visibleTextQuery,
+ promoteTapTarget,
+ );
+ if (!shouldInferMaestroTabSlot(best, visibleTextQuery, promoteTapTarget)) return best;
+ return inferMaestroMissingTabSlotMatch(nodes, best, visibleTextQuery!) ?? best;
+}
+
+function selectPreferredMaestroSnapshotMatch(
+ nodes: SnapshotState['nodes'],
+ candidates: MaestroResolvedSnapshotMatch[],
+ visibleTextQuery: string | null,
+ promoteTapTarget: boolean,
+): MaestroResolvedSnapshotMatch | null {
+ if (!promoteTapTarget || !visibleTextQuery) {
+ return selectBestMaestroSnapshotMatch(candidates, visibleTextQuery);
+ }
+ return (
+ selectLocalizedMaestroVisibleTextMatch(nodes, candidates, visibleTextQuery) ??
+ selectBestMaestroSnapshotMatch(candidates, visibleTextQuery)
+ );
+}
+
+function shouldInferMaestroTabSlot(
+ match: MaestroResolvedSnapshotMatch | null,
+ visibleTextQuery: string | null,
+ promoteTapTarget: boolean,
+): match is MaestroResolvedSnapshotMatch {
+ return Boolean(promoteTapTarget && visibleTextQuery && match);
}
function selectBestMaestroSnapshotMatch(
@@ -389,6 +422,69 @@ function selectBestMaestroSnapshotMatch(
);
}
+function selectLocalizedMaestroVisibleTextMatch(
+ nodes: SnapshotState['nodes'],
+ candidates: MaestroResolvedSnapshotMatch[],
+ query: string,
+): MaestroResolvedSnapshotMatch | null {
+ const exactMatches = candidates.filter(
+ (candidate) => maestroVisibleTextMatchRank(candidate.node, query) === 0,
+ );
+ if (exactMatches.length >= 2) {
+ const localizedExact = selectLocalizedMaestroVisibleTextMatchFromCandidates(
+ nodes,
+ exactMatches,
+ query,
+ );
+ if (localizedExact) return localizedExact;
+ }
+
+ const normalizedMatches = candidates.filter(
+ (candidate) => maestroVisibleTextMatchRank(candidate.node, query) === 1,
+ );
+ if (exactMatches.length > 0 || normalizedMatches.length < 2) return null;
+
+ return selectLocalizedMaestroVisibleTextMatchFromCandidates(nodes, normalizedMatches, query);
+}
+
+function selectLocalizedMaestroVisibleTextMatchFromCandidates(
+ nodes: SnapshotState['nodes'],
+ candidates: MaestroResolvedSnapshotMatch[],
+ query: string,
+): MaestroResolvedSnapshotMatch | null {
+ const nodeByIndex = buildSnapshotNodeByIndex(nodes);
+ const localized = candidates.filter(
+ (candidate) =>
+ isLocalizedMaestroVisibleTextCandidate(candidate) &&
+ candidates.some((container) =>
+ isMaestroVisibleTextContainerForCandidate(nodes, container, candidate, nodeByIndex),
+ ),
+ );
+
+ return selectBestMaestroSnapshotMatch(localized, query);
+}
+
+function isLocalizedMaestroVisibleTextCandidate(match: MaestroResolvedSnapshotMatch): boolean {
+ return (
+ match.rect.width >= 16 &&
+ match.rect.width <= 260 &&
+ match.rect.height >= 24 &&
+ match.rect.height <= 80
+ );
+}
+
+function isMaestroVisibleTextContainerForCandidate(
+ nodes: SnapshotState['nodes'],
+ container: MaestroResolvedSnapshotMatch,
+ candidate: MaestroResolvedSnapshotMatch,
+ nodeByIndex: SnapshotNodeByIndex,
+): boolean {
+ if (container.node.index === candidate.node.index) return false;
+ if (!rectContains(container.rect, candidate.rect)) return false;
+ if (rectArea(container.rect) < rectArea(candidate.rect) * 2) return false;
+ return isDescendantOfSnapshotNode(nodes, candidate.node, container.node, nodeByIndex);
+}
+
function preferOnScreenMatches(
matches: MaestroResolvedSnapshotMatch[],
frame: TouchReferenceFrame | undefined,
@@ -476,24 +572,88 @@ function inferMaestroMissingTabSlotMatch(
query: string,
): MaestroResolvedSnapshotMatch | null {
if (!isMaestroTabStripContainerMatch(match, query)) return null;
- const children: Array = [];
- for (const node of nodes) {
- if (node.parentIndex !== match.node.index || !node.rect) continue;
- const candidate = node as SnapshotNode & { rect: Rect };
- if (isMaestroTabStripChildCandidate(candidate, match.rect, query)) {
- children.push(candidate);
- }
- }
- children.sort((left, right) => left.rect.x - right.rect.x);
+ const children = collectMaestroTabStripChildCandidates(nodes, match, query);
if (children.length === 0) return null;
const medianChildWidth = median(children.map((child) => child.rect.width));
- const gaps = resolveHorizontalGaps(
+ const allGaps = resolveHorizontalGaps(
match.rect,
children.map((child) => child.rect),
- ).filter((gap) => isPlausibleMissingTabSlot(gap.width, medianChildWidth));
- if (gaps.length !== 1) return null;
- const gap = gaps[0];
+ );
+ const gap = selectMaestroMissingSlotGap(match, query, allGaps, medianChildWidth);
if (!gap) return null;
+ return matchWithRect(match, gap);
+}
+
+function collectMaestroTabStripChildCandidates(
+ nodes: SnapshotState['nodes'],
+ match: MaestroResolvedSnapshotMatch,
+ query: string,
+): Array {
+ return nodes
+ .filter((node): node is SnapshotNode & { rect: Rect } => {
+ return (
+ node.parentIndex === match.node.index &&
+ Boolean(node.rect) &&
+ isMaestroTabStripChildCandidate(node as SnapshotNode & { rect: Rect }, match.rect, query)
+ );
+ })
+ .sort((left, right) => left.rect.x - right.rect.x);
+}
+
+function selectMaestroMissingSlotGap(
+ match: MaestroResolvedSnapshotMatch,
+ query: string,
+ gaps: Array<{ x: number; width: number }>,
+ medianChildWidth: number,
+): { x: number; width: number } | null {
+ const plausibleGaps = gaps.filter((gap) => isPlausibleMissingTabSlot(gap.width, medianChildWidth));
+ const leadingTextSlot = inferMaestroLeadingTextSlotGap(match, query, gaps);
+ const hasPlausibleLeadingGap = plausibleGaps.some((gap) => isLeadingGap(match.rect, gap));
+ if (leadingTextSlot && !hasPlausibleLeadingGap) return leadingTextSlot;
+ if (plausibleGaps.length === 1) return plausibleGaps[0] ?? null;
+ return leadingTextSlot;
+}
+
+function inferMaestroLeadingTextSlotGap(
+ match: MaestroResolvedSnapshotMatch,
+ query: string,
+ gaps: Array<{ x: number; width: number }>,
+): { x: number; width: number } | null {
+ const leadingGap = gaps.find((gap) => Math.abs(gap.x - match.rect.x) < 1);
+ const estimatedLabelWidth = Math.max(48, Math.min(220, query.length * 8 + 24));
+ if (!isLeadingTextSlotCandidate(match, query, leadingGap, estimatedLabelWidth)) return null;
+ return {
+ x: match.rect.x,
+ width: Math.min(estimatedLabelWidth, leadingGap.width),
+ };
+}
+
+function isLeadingTextSlotCandidate(
+ match: MaestroResolvedSnapshotMatch,
+ query: string,
+ gap: { x: number; width: number } | undefined,
+ estimatedLabelWidth: number,
+): gap is { x: number; width: number } {
+ if (!gap) return false;
+ return (
+ normalizeType(match.node.type ?? '') === 'scrollview' &&
+ maestroVisibleTextMatchRank(match.node, query) <= 1 &&
+ match.rect.width >= 240 &&
+ match.rect.height >= 32 &&
+ match.rect.height <= 80 &&
+ gap.width <= match.rect.width * 0.55 &&
+ gap.width >= estimatedLabelWidth * 0.6
+ );
+}
+
+function isLeadingGap(rect: Rect, gap: { x: number; width: number }): boolean {
+ return Math.abs(gap.x - rect.x) < 1;
+}
+
+function matchWithRect(
+ match: MaestroResolvedSnapshotMatch,
+ gap: { x: number; width: number },
+): MaestroResolvedSnapshotMatch {
return {
...match,
rect: {
diff --git a/src/compat/maestro/support-matrix.ts b/src/compat/maestro/support-matrix.ts
new file mode 100644
index 000000000..73b579f46
--- /dev/null
+++ b/src/compat/maestro/support-matrix.ts
@@ -0,0 +1,42 @@
+export const MAESTRO_COMPAT_SUPPORTED_CAPABILITIES = [
+ 'app launch with Apple-platform launch arguments and iOS simulator clearState',
+ 'runFlow file/inline with when.platform, when.visible, when.notVisible, and limited when.true boolean/platform expressions',
+ 'onFlowStart and onFlowComplete hooks',
+ 'deterministic repeat.times',
+ 'tapOn including optional, index, childOf, label, and absolute/percentage point taps',
+ 'doubleTapOn and longPressOn',
+ 'inputText, focused-field eraseText, and pasteText',
+ 'openLink',
+ 'visibility assertions and extendedWaitUntil',
+ 'scroll and scrollUntilVisible',
+ 'absolute/percentage swipe and swipe.label',
+ 'screenshots',
+ 'keyboard dismiss',
+ 'basic pressKey, back, animation waits, and stopApp',
+ 'ordered trusted runScript file/env scripts with http.post, json, and output variables',
+] as const;
+
+export const MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES = [
+ 'repeat.while',
+ 'full expression predicates beyond boolean literals and maestro.platform comparisons',
+ 'evalScript',
+ 'device utility commands',
+ 'Android app launch arguments',
+ 'Android app state reset',
+] as const;
+
+export const MAESTRO_COMPAT_TRACKER_URL =
+ 'https://github.com/callstackincubator/agent-device/issues/558';
+
+export const MAESTRO_NEW_ISSUE_URL =
+ 'https://github.com/callstackincubator/agent-device/issues/new';
+
+export function formatMaestroSupportedSubsetForCli(): string {
+ return `Supported subset: ${formatMaestroCapabilityList(MAESTRO_COMPAT_SUPPORTED_CAPABILITIES)}.`;
+}
+
+export function formatMaestroCapabilityList(capabilities: readonly string[]): string {
+ return capabilities.length > 1
+ ? `${capabilities.slice(0, -1).join(', ')}, and ${capabilities.at(-1)}`
+ : (capabilities[0] ?? '');
+}
diff --git a/src/compat/maestro/support.ts b/src/compat/maestro/support.ts
index 0d26a98e3..1dc5a56f7 100644
--- a/src/compat/maestro/support.ts
+++ b/src/compat/maestro/support.ts
@@ -1,10 +1,8 @@
import type { SessionAction } from '../../daemon/types.ts';
import { AppError } from '../../utils/errors.ts';
+import { MAESTRO_COMPAT_TRACKER_URL, MAESTRO_NEW_ISSUE_URL } from './support-matrix.ts';
import type { MaestroCommand, MaestroFlowConfig, MaestroParseContext } from './types.ts';
-const MAESTRO_COMPAT_TRACKER_URL = 'https://github.com/callstackincubator/agent-device/issues/558';
-const MAESTRO_NEW_ISSUE_URL = 'https://github.com/callstackincubator/agent-device/issues/new';
-
export function action(
command: string,
positionals: string[] = [],
diff --git a/src/daemon/handlers/__tests__/session-open-runtime.test.ts b/src/daemon/handlers/__tests__/session-open-runtime.test.ts
index fa3f43ce0..7d6b7e68a 100644
--- a/src/daemon/handlers/__tests__/session-open-runtime.test.ts
+++ b/src/daemon/handlers/__tests__/session-open-runtime.test.ts
@@ -24,7 +24,6 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
return {
...actual,
prewarmIosRunnerSession: vi.fn(),
- prewarmIosRunnerXctestrun: vi.fn(),
stopIosRunnerSession: vi.fn(async () => {}),
};
});
diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts
index c9f121eba..8196dc708 100644
--- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts
+++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts
@@ -888,11 +888,10 @@ test('runReplayScriptFile treats absent Maestro assertNotVisible targets as pass
invoke: async (req) => {
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
return {
- ok: false,
- error: {
- code: 'COMMAND_FAILED',
- message: 'Selector did not match',
- details: { command: 'is', reason: 'selector_not_found' },
+ ok: true,
+ data: {
+ createdAt: 1,
+ nodes: [],
},
};
},
@@ -902,14 +901,8 @@ test('runReplayScriptFile treats absent Maestro assertNotVisible targets as pass
assert.deepEqual(
calls.map((call) => [call.command, call.positionals]),
[
- [
- 'is',
- ['visible', 'label="Archived banner" || text="Archived banner" || id="Archived banner"'],
- ],
- [
- 'is',
- ['visible', 'label="Archived banner" || text="Archived banner" || id="Archived banner"'],
- ],
+ ['snapshot', []],
+ ['snapshot', []],
],
);
assert.equal(calls[0]?.flags?.noRecord, true);
@@ -940,21 +933,34 @@ test('runReplayScriptFile propagates Maestro assertNotVisible infrastructure fai
test('runReplayScriptFile waits briefly for Maestro assertNotVisible to stabilize', async () => {
const calls: CapturedInvocation[] = [];
- let visibleChecks = 0;
+ let snapshots = 0;
const { response } = await runReplayFixture({
label: 'maestro-assert-not-visible-stable',
script: ['appId: demo.app', '---', '- assertNotVisible: Archived banner', ''].join('\n'),
flags: { replayBackend: 'maestro' },
invoke: async (req) => {
calls.push({ command: req.command, positionals: req.positionals, flags: req.flags });
- visibleChecks += 1;
- if (visibleChecks === 1) return { ok: true, data: { pass: true } };
+ snapshots += 1;
+ if (snapshots === 1) {
+ return {
+ ok: true,
+ data: {
+ createdAt: 1,
+ nodes: [
+ {
+ index: 1,
+ label: 'Archived banner',
+ rect: { x: 10, y: 20, width: 180, height: 44 },
+ },
+ ],
+ },
+ };
+ }
return {
- ok: false,
- error: {
- code: 'COMMAND_FAILED',
- message: 'is visible failed',
- details: { command: 'is', reason: 'predicate_failed' },
+ ok: true,
+ data: {
+ createdAt: snapshots,
+ nodes: [],
},
};
},
@@ -1473,7 +1479,7 @@ test('runReplayScriptFile uses Android content lane for Maestro horizontal scree
calls.map((call) => [call.command, call.positionals]),
[
['snapshot', []],
- ['swipe', ['320', '520', '80', '520', '300']],
+ ['swipe', ['280', '520', '120', '520', '300']],
['swipe', ['360', '520', '40', '520', '300']],
],
);
diff --git a/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts b/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts
index ccedb58e3..e4257b99a 100644
--- a/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts
+++ b/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts
@@ -28,6 +28,19 @@ test('isReplayInfrastructureFailure keeps message fallback for legacy errors', (
assert.equal(isReplayInfrastructureFailure(response), true);
});
+test('isReplayInfrastructureFailure accepts replay timeout cleanup races', () => {
+ const response: DaemonResponse = {
+ ok: false,
+ error: {
+ code: 'COMMAND_FAILED',
+ message: 'TIMEOUT after 120000ms',
+ details: { reason: 'timeout_cleanup_pending', timeoutCleanupPending: true },
+ },
+ };
+
+ assert.equal(isReplayInfrastructureFailure(response), true);
+});
+
test('isReplayInfrastructureFailure rejects normal replay failures', () => {
const response: DaemonResponse = {
ok: false,
diff --git a/src/daemon/handlers/__tests__/session-test-runtime.test.ts b/src/daemon/handlers/__tests__/session-test-runtime.test.ts
index e9e72ea76..087fc87ee 100644
--- a/src/daemon/handlers/__tests__/session-test-runtime.test.ts
+++ b/src/daemon/handlers/__tests__/session-test-runtime.test.ts
@@ -33,6 +33,8 @@ test('runReplayTestAttempt keeps cancellation active until a timed-out replay se
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain('TIMEOUT after 10ms');
+ expect(result.error.details?.reason).toBe('timeout_cleanup_pending');
+ expect(result.error.details?.timeoutCleanupPending).toBe(true);
}
expect(cleanupSession).toHaveBeenCalledWith('default:test:timeout');
expect(isRequestCanceled('req-timeout-open')).toBe(true);
@@ -42,8 +44,10 @@ test('runReplayTestAttempt keeps cancellation active until a timed-out replay se
error: { code: 'COMMAND_FAILED', message: 'request canceled' },
});
await replaySettled;
- await Promise.resolve();
- await Promise.resolve();
-
- expect(isRequestCanceled('req-timeout-open')).toBe(false);
+ await vi.waitFor(() => {
+ expect(isRequestCanceled('req-timeout-open')).toBe(false);
+ });
+ await vi.waitFor(() => {
+ expect(cleanupSession).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts
index 61d5470ac..53d6713e0 100644
--- a/src/daemon/handlers/__tests__/session.test.ts
+++ b/src/daemon/handlers/__tests__/session.test.ts
@@ -18,7 +18,6 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
return {
...actual,
prewarmIosRunnerSession: vi.fn(),
- prewarmIosRunnerXctestrun: vi.fn(),
stopIosRunnerSession: vi.fn(async () => {}),
};
});
@@ -64,6 +63,7 @@ vi.mock('../../../platforms/ios/apps.ts', async (importOriginal) => {
...actual,
listIosApps: vi.fn(async () => []),
resolveIosApp: vi.fn(async () => undefined),
+ resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined),
};
});
vi.mock('../../app-log.ts', async (importOriginal) => {
@@ -96,7 +96,6 @@ import { ensureDeviceReady } from '../../device-ready.ts';
import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts';
import {
prewarmIosRunnerSession,
- prewarmIosRunnerXctestrun,
stopIosRunnerSession,
} from '../../../platforms/ios/runner-client.ts';
import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts';
@@ -108,7 +107,7 @@ import {
ensureAndroidEmulatorBooted,
} from '../../../platforms/android/devices.ts';
import { listAppleDevices } from '../../../platforms/ios/devices.ts';
-import { resolveIosApp } from '../../../platforms/ios/apps.ts';
+import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId } from '../../../platforms/ios/apps.ts';
import { startAppLog, stopAppLog } from '../../app-log.ts';
import { defaultInstallOps, defaultReinstallOps } from '../session-deploy.ts';
import { clearRequestCanceled, markRequestCanceled } from '../../request-cancel.ts';
@@ -119,7 +118,6 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady);
const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp);
const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp);
const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession);
-const mockPrewarmIosRunnerXctestrun = vi.mocked(prewarmIosRunnerXctestrun);
const mockStopIosRunner = vi.mocked(stopIosRunnerSession);
const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction);
const mockSettleSimulator = vi.mocked(settleIosSimulator);
@@ -129,6 +127,7 @@ const mockRunCmd = vi.mocked(runCmd);
const mockListAndroidDevices = vi.mocked(listAndroidDevices);
const mockListAppleDevices = vi.mocked(listAppleDevices);
const mockResolveIosApp = vi.mocked(resolveIosApp);
+const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked(resolveIosSimulatorDeepLinkBundleId);
const mockEnsureAndroidEmulatorBooted = vi.mocked(ensureAndroidEmulatorBooted);
const mockStartAppLog = vi.mocked(startAppLog);
const mockStopAppLog = vi.mocked(stopAppLog);
@@ -149,7 +148,6 @@ beforeEach(() => {
mockClearRuntimeHints.mockReset();
mockClearRuntimeHints.mockResolvedValue(undefined);
mockPrewarmIosRunnerSession.mockReset();
- mockPrewarmIosRunnerXctestrun.mockReset();
mockStopIosRunner.mockReset();
mockStopIosRunner.mockResolvedValue(undefined);
mockDismissMacOsAlert.mockReset();
@@ -177,6 +175,8 @@ beforeEach(() => {
}
return app.includes('.') ? app : `com.example.${normalizedApp}`;
});
+ mockResolveIosSimulatorDeepLinkBundleId.mockReset();
+ mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue(undefined);
mockEnsureAndroidEmulatorBooted.mockReset();
mockStartAppLog.mockReset();
mockStopAppLog.mockReset();
@@ -1865,6 +1865,101 @@ test('open URL on existing iOS device session preserves app bundle id context',
expect(dispatchedContext?.appBundleId).toBe('com.example.app');
});
+test('open custom URL on existing iOS simulator session preserves app bundle id context', async () => {
+ const sessionStore = makeSessionStore();
+ const sessionName = 'ios-simulator-session';
+ sessionStore.set(sessionName, {
+ ...makeSession(sessionName, {
+ platform: 'ios',
+ id: 'sim-1',
+ name: 'iPhone 17 Pro',
+ kind: 'simulator',
+ booted: true,
+ }),
+ appBundleId: 'com.example.app',
+ appName: 'Example App',
+ });
+ mockResolveTargetDevice.mockResolvedValue({
+ platform: 'ios',
+ id: 'sim-1',
+ name: 'iPhone 17 Pro',
+ kind: 'simulator',
+ booted: true,
+ });
+
+ let dispatchedContext: Record | undefined;
+ mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => {
+ dispatchedContext = context as Record | undefined;
+ return {};
+ });
+
+ const response = await handleSessionCommands({
+ req: {
+ token: 't',
+ session: sessionName,
+ command: 'open',
+ positionals: ['myapp://item/42'],
+ flags: {},
+ },
+ sessionName,
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
+ sessionStore,
+ invoke: noopInvoke,
+ });
+
+ expect(response).toBeTruthy();
+ expect(response?.ok).toBe(true);
+ const updated = sessionStore.get(sessionName);
+ expect(updated?.appBundleId).toBe('com.example.app');
+ expect(updated?.appName).toBe('myapp://item/42');
+ expect(dispatchedContext?.appBundleId).toBe('com.example.app');
+});
+
+test('open custom URL on fresh iOS simulator session infers app bundle id from URL scheme', async () => {
+ const sessionStore = makeSessionStore();
+ const sessionName = 'ios-simulator-url-session';
+ mockResolveTargetDevice.mockResolvedValue({
+ platform: 'ios',
+ id: 'sim-1',
+ name: 'iPhone 17 Pro',
+ kind: 'simulator',
+ booted: true,
+ });
+ mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue('org.reactnavigation.playground');
+
+ let dispatchedContext: Record | undefined;
+ mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => {
+ dispatchedContext = context as Record | undefined;
+ return {};
+ });
+
+ const response = await handleSessionCommands({
+ req: {
+ token: 't',
+ session: sessionName,
+ command: 'open',
+ positionals: ['rne://navigator-layout'],
+ flags: { platform: 'ios', udid: 'sim-1' },
+ },
+ sessionName,
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
+ sessionStore,
+ invoke: noopInvoke,
+ });
+
+ expect(response).toBeTruthy();
+ expect(response?.ok).toBe(true);
+ expect(mockResolveIosSimulatorDeepLinkBundleId).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'sim-1', kind: 'simulator' }),
+ 'rne://navigator-layout',
+ );
+ const updated = sessionStore.get(sessionName);
+ expect(updated?.appBundleId).toBe('org.reactnavigation.playground');
+ expect(updated?.appName).toBe('rne://navigator-layout');
+ expect(dispatchedContext?.appBundleId).toBe('org.reactnavigation.playground');
+ expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1);
+});
+
test('open iOS app session prewarms runner session when app bundle id is known', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-device-session';
@@ -1901,10 +1996,9 @@ test('open iOS app session prewarms runner session when app bundle id is known',
expect.objectContaining({ platform: 'ios', id: 'ios-device-1' }),
expect.objectContaining({ logPath: expect.stringMatching(/daemon\.log$/) }),
);
- expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled();
});
-test('open iOS URL without app bundle id keeps xctestrun-only prewarm', async () => {
+test('open iOS URL without app bundle id skips runner prewarm', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-device-session';
sessionStore.set(
@@ -1935,7 +2029,6 @@ test('open iOS URL without app bundle id keeps xctestrun-only prewarm', async ()
expect(response).toBeTruthy();
expect(response?.ok).toBe(true);
expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled();
- expect(mockPrewarmIosRunnerXctestrun).toHaveBeenCalledTimes(1);
});
test('open web URL on iOS device session without active app falls back to Safari', async () => {
diff --git a/src/daemon/handlers/session-open-target.ts b/src/daemon/handlers/session-open-target.ts
index d7f14e214..93100db73 100644
--- a/src/daemon/handlers/session-open-target.ts
+++ b/src/daemon/handlers/session-open-target.ts
@@ -1,4 +1,8 @@
-import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
+import {
+ isDeepLinkTarget,
+ isWebUrl,
+ resolveIosDeviceDeepLinkBundleId,
+} from '../../core/open-target.ts';
import type { DeviceInfo } from '../../utils/device.ts';
async function resolveIosBundleIdForOpen(
@@ -12,11 +16,28 @@ async function resolveIosBundleIdForOpen(
if (device.kind === 'device') {
return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget);
}
+ if (!isWebUrl(openTarget)) {
+ return (
+ currentAppBundleId ?? (await tryResolveIosSimulatorDeepLinkBundleId(device, openTarget))
+ );
+ }
return undefined;
}
return await tryResolveIosAppBundleId(device, openTarget);
}
+async function tryResolveIosSimulatorDeepLinkBundleId(
+ device: DeviceInfo,
+ openTarget: string,
+): Promise {
+ try {
+ const { resolveIosSimulatorDeepLinkBundleId } = await import('../../platforms/ios/apps.ts');
+ return await resolveIosSimulatorDeepLinkBundleId(device, openTarget);
+ } catch {
+ return undefined;
+ }
+}
+
async function tryResolveIosAppBundleId(
device: DeviceInfo,
openTarget: string,
diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts
index e344edc57..be5b084d8 100644
--- a/src/daemon/handlers/session-open.ts
+++ b/src/daemon/handlers/session-open.ts
@@ -5,7 +5,6 @@ import { contextFromFlags } from '../context.ts';
import { createRequestCanceledError, isRequestCanceled } from '../request-cancel.ts';
import {
prewarmIosRunnerSession,
- prewarmIosRunnerXctestrun,
stopIosRunnerSession,
} from '../../platforms/ios/runner-client.ts';
import { applyRuntimeHintsToApp } from '../runtime-hints.ts';
@@ -183,10 +182,6 @@ async function completeOpenCommand(params: {
timing.runnerPrewarmKind = 'session';
timing.runnerPrewarmScheduled = true;
runnerPrewarm = prewarmIosRunnerSession(device, runnerPrewarmOptions);
- } else if (shouldPrewarmIosRunner) {
- timing.runnerPrewarmKind = 'xctestrun';
- timing.runnerPrewarmScheduled = true;
- runnerPrewarm = prewarmIosRunnerXctestrun(device, runnerPrewarmOptions);
}
const openStartedAtMs = Date.now();
await dispatchCommand(device, 'open', openPositionals, req.flags?.out, {
diff --git a/src/daemon/handlers/session-test-infrastructure.ts b/src/daemon/handlers/session-test-infrastructure.ts
index 77d7ead55..85b515e10 100644
--- a/src/daemon/handlers/session-test-infrastructure.ts
+++ b/src/daemon/handlers/session-test-infrastructure.ts
@@ -32,6 +32,7 @@ function readReplayFailureError(
function hasInfrastructureFailureReason(details: Record | undefined): boolean {
const reason = typeof details?.reason === 'string' ? details.reason : '';
+ if (reason === 'timeout_cleanup_pending') return true;
return reason ? isInfrastructureBootFailureReason(reason) : false;
}
diff --git a/src/daemon/handlers/session-test-runtime.ts b/src/daemon/handlers/session-test-runtime.ts
index 8b8d74677..74cc4622b 100644
--- a/src/daemon/handlers/session-test-runtime.ts
+++ b/src/daemon/handlers/session-test-runtime.ts
@@ -13,6 +13,7 @@ import type { ReplayTestRuntimeDependencies } from './session-test-types.ts';
const REPLAY_TIMEOUT_CLEANUP_GRACE_MS = 2_000;
const REPLAY_TEST_TIMEOUT_HINT =
'Replay test timeouts are cooperative; the active command may take a short grace period to stop.';
+const REPLAY_TIMEOUT_CLEANUP_PENDING_REASON = 'timeout_cleanup_pending';
export async function runReplayTestAttempt(
params: {
@@ -40,6 +41,7 @@ export async function runReplayTestAttempt(
const artifactPaths = new Set();
let timeoutHandle: ReturnType | undefined;
let timedOut = false;
+ let response: DaemonResponse | undefined;
const replayPromise = runReplay({
filePath,
sessionName,
@@ -61,7 +63,7 @@ export async function runReplayTestAttempt(
});
try {
- const response =
+ response =
typeof timeoutMs === 'number'
? await Promise.race([
replayPromise,
@@ -80,6 +82,7 @@ export async function runReplayTestAttempt(
if (timedOut) {
const settled = await waitForReplayAfterTimeout(replayPromise);
if (!settled) {
+ markReplayTimeoutCleanupPending(response);
emitDiagnostic({
level: 'warn',
phase: 'test_timeout_cleanup_race',
@@ -89,6 +92,12 @@ export async function runReplayTestAttempt(
graceMs: REPLAY_TIMEOUT_CLEANUP_GRACE_MS,
},
});
+ void cleanupSessionAfterLateReplay({
+ replayPromise,
+ cleanupSession,
+ sessionName,
+ requestId,
+ });
}
}
try {
@@ -114,6 +123,42 @@ async function waitForReplayAfterTimeout(replayPromise: Promise)
]);
}
+async function cleanupSessionAfterLateReplay(params: {
+ replayPromise: Promise;
+ cleanupSession: ReplayTestRuntimeDependencies['cleanupSession'];
+ sessionName: string;
+ requestId: string;
+}): Promise {
+ const { replayPromise, cleanupSession, sessionName, requestId } = params;
+ try {
+ await replayPromise;
+ } finally {
+ try {
+ await cleanupSession(sessionName);
+ } catch (error) {
+ const appErr = normalizeError(error);
+ emitDiagnostic({
+ level: 'warn',
+ phase: 'test_late_cleanup_failed',
+ data: {
+ session: sessionName,
+ requestId,
+ error: appErr.message,
+ },
+ });
+ }
+ }
+}
+
+function markReplayTimeoutCleanupPending(response: DaemonResponse | undefined): void {
+ if (!response || response.ok) return;
+ response.error.details = {
+ ...(response.error.details ?? {}),
+ reason: REPLAY_TIMEOUT_CLEANUP_PENDING_REASON,
+ timeoutCleanupPending: true,
+ };
+}
+
function createReplayTestTimeoutResponse(
timeoutMs: number,
artifactPaths: string[] = [],
diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts
index 0633b79a9..47e1351fa 100644
--- a/src/platforms/android/__tests__/index.test.ts
+++ b/src/platforms/android/__tests__/index.test.ts
@@ -1720,6 +1720,35 @@ test('getAndroidKeyboardState uses latest visibility value when dumpsys contains
);
});
+test('getAndroidKeyboardState treats stale input view as hidden when the IME window is hidden', async () => {
+ await withMockedAdb(
+ 'agent-device-android-keyboard-stale-input-view-',
+ [
+ '#!/bin/sh',
+ 'if [ "$1" = "-s" ]; then',
+ ' shift',
+ ' shift',
+ 'fi',
+ 'if [ "$1" = "shell" ] && [ "$2" = "dumpsys" ] && [ "$3" = "input_method" ]; then',
+ ' echo "mInputShown=false"',
+ ' echo "mDecorViewVisible=false mWindowVisible=false mInShowWindow=false"',
+ ' echo "mIsInputViewShown=true"',
+ ' echo "inputType=0x21"',
+ ' exit 0',
+ 'fi',
+ 'echo "unexpected args: $@" >&2',
+ 'exit 1',
+ '',
+ ].join('\n'),
+ async ({ device }) => {
+ const state = await getAndroidKeyboardState(device);
+ assert.equal(state.visible, false);
+ assert.equal(state.inputType, '0x21');
+ assert.equal(state.type, 'email');
+ },
+ );
+});
+
test('dismissAndroidKeyboard skips keyevent when keyboard is already hidden', async () => {
await withMockedAdb(
'agent-device-android-keyboard-dismiss-hidden-',
diff --git a/src/platforms/android/__tests__/snapshot-helper-session.test.ts b/src/platforms/android/__tests__/snapshot-helper-session.test.ts
new file mode 100644
index 000000000..ca1665685
--- /dev/null
+++ b/src/platforms/android/__tests__/snapshot-helper-session.test.ts
@@ -0,0 +1,234 @@
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import net from 'node:net';
+import { PassThrough } from 'node:stream';
+import { afterEach, beforeEach, test } from 'vitest';
+import {
+ captureAndroidSnapshotWithHelperSession,
+ resetAndroidSnapshotHelperSessions,
+} from '../snapshot-helper.ts';
+import type { AndroidAdbExecutor, AndroidAdbProcess, AndroidAdbProvider } from '../adb-executor.ts';
+
+beforeEach(async () => {
+ delete process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION;
+ await resetAndroidSnapshotHelperSessions();
+});
+
+afterEach(async () => {
+ delete process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION;
+ await resetAndroidSnapshotHelperSessions();
+});
+
+test('returns undefined when persistent sessions are disabled', async () => {
+ process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION = '0';
+ const calls: string[][] = [];
+ const provider = createSessionProvider({ calls });
+
+ const output = await captureAndroidSnapshotWithHelperSession({
+ adb: provider.exec,
+ adbProvider: provider,
+ });
+
+ assert.equal(output, undefined);
+ assert.deepEqual(calls, []);
+});
+
+test('returns undefined when the adb provider cannot spawn a helper process', async () => {
+ const calls: string[][] = [];
+ const adb: AndroidAdbExecutor = async (args) => {
+ calls.push(args);
+ return { exitCode: 0, stdout: '', stderr: '' };
+ };
+
+ const output = await captureAndroidSnapshotWithHelperSession({ adb });
+
+ assert.equal(output, undefined);
+ assert.deepEqual(calls, []);
+});
+
+test('starts and reuses a persistent Android snapshot helper session', async () => {
+ const calls: string[][] = [];
+ const spawnArgs: string[][] = [];
+ const provider = createSessionProvider({ calls, spawnArgs });
+
+ const first = await captureAndroidSnapshotWithHelperSession({
+ adb: provider.exec,
+ adbProvider: provider,
+ deviceKey: 'android:emulator-5554',
+ helperVersion: '0.16.2',
+ helperVersionCode: 16002,
+ });
+ const second = await captureAndroidSnapshotWithHelperSession({
+ adb: provider.exec,
+ adbProvider: provider,
+ deviceKey: 'android:emulator-5554',
+ helperVersion: '0.16.2',
+ helperVersionCode: 16002,
+ });
+
+ assert.match(first?.xml ?? '', /snapshot 1/);
+ assert.equal(first?.metadata.transport, 'persistent-session');
+ assert.equal(first?.metadata.sessionReused, false);
+ assert.equal(first?.metadata.elapsedMs, 7);
+ assert.match(second?.xml ?? '', /snapshot 2/);
+ assert.equal(second?.metadata.transport, 'persistent-session');
+ assert.equal(second?.metadata.sessionReused, true);
+ assert.equal(spawnArgs.length, 1);
+ assert.equal(calls.filter((args) => args[0] === 'forward' && args[1]?.startsWith('tcp:')).length, 1);
+});
+
+test('restarts the helper session when capture options change', async () => {
+ const calls: string[][] = [];
+ const spawnArgs: string[][] = [];
+ const provider = createSessionProvider({ calls, spawnArgs });
+
+ await captureAndroidSnapshotWithHelperSession({
+ adb: provider.exec,
+ adbProvider: provider,
+ deviceKey: 'android:emulator-5554',
+ waitForIdleTimeoutMs: 25,
+ });
+ const restarted = await captureAndroidSnapshotWithHelperSession({
+ adb: provider.exec,
+ adbProvider: provider,
+ deviceKey: 'android:emulator-5554',
+ waitForIdleTimeoutMs: 50,
+ });
+
+ assert.equal(restarted?.metadata.sessionReused, false);
+ assert.equal(spawnArgs.length, 2);
+ assert.equal(calls.some((args) => args[0] === 'forward' && args[1] === '--remove'), true);
+});
+
+test('invalidates the helper session after a malformed response', async () => {
+ const calls: string[][] = [];
+ const provider = createSessionProvider({ calls, responseMode: 'malformed' });
+
+ await assert.rejects(
+ () =>
+ captureAndroidSnapshotWithHelperSession({
+ adb: provider.exec,
+ adbProvider: provider,
+ deviceKey: 'android:emulator-5554',
+ }),
+ {
+ message: 'Android snapshot helper session returned malformed output',
+ },
+ );
+
+ assert.equal(calls.some((args) => args[0] === 'forward' && args[1] === '--remove'), true);
+});
+
+function createSessionProvider(options: {
+ calls: string[][];
+ spawnArgs?: string[][];
+ responseMode?: 'ok' | 'malformed';
+}): AndroidAdbProvider {
+ return {
+ exec: async (args) => {
+ options.calls.push(args);
+ return { exitCode: 0, stdout: '', stderr: '' };
+ },
+ spawn: (args) => {
+ options.spawnArgs?.push(args);
+ const port = readSessionPort(args);
+ const process = new FakeAndroidProcess();
+ let snapshotCount = 0;
+ const server = net.createServer((socket) => {
+ socket.once('data', (chunk) => {
+ const command = chunk.toString('utf8').trim();
+ const [, requestId = ''] = command.split(/\s+/, 2);
+ if (command.startsWith('quit')) {
+ socket.end(sessionResponse({ requestId, body: '' }));
+ server.close(() => process.emitExit(0, null));
+ return;
+ }
+ if (options.responseMode === 'malformed') {
+ socket.end('not a session response');
+ return;
+ }
+ snapshotCount += 1;
+ const body = ``;
+ socket.end(
+ sessionResponse({
+ requestId,
+ body,
+ metadata: {
+ waitForIdleTimeoutMs: '25',
+ waitForIdleQuietMs: '25',
+ timeoutMs: '5000',
+ maxDepth: '128',
+ maxNodes: '5000',
+ rootPresent: 'true',
+ captureMode: 'interactive-windows',
+ windowCount: '1',
+ nodeCount: '1',
+ truncated: 'false',
+ elapsedMs: '7',
+ },
+ }),
+ );
+ });
+ });
+ server.listen(port, '127.0.0.1', () => {
+ process.stdout.write(
+ [
+ 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1',
+ 'INSTRUMENTATION_STATUS: sessionReady=true',
+ 'INSTRUMENTATION_STATUS_CODE: 2',
+ '',
+ ].join('\n'),
+ );
+ });
+ process.onKill = () => {
+ server.close(() => process.emitExit(0, null));
+ };
+ return process;
+ },
+ };
+}
+
+function sessionResponse(params: {
+ requestId: string;
+ body: string;
+ metadata?: Record;
+}): string {
+ const bodyLength = Buffer.byteLength(params.body, 'utf8');
+ const headers = {
+ agentDeviceProtocol: 'android-snapshot-helper-v1',
+ helperApiVersion: '1',
+ outputFormat: 'uiautomator-xml',
+ requestId: params.requestId,
+ ok: 'true',
+ byteLength: String(bodyLength),
+ ...params.metadata,
+ };
+ return `${Object.entries(headers)
+ .map(([key, value]) => `${key}=${value}`)
+ .join('\n')}\n\n${params.body}`;
+}
+
+function readSessionPort(args: string[]): number {
+ const index = args.indexOf('sessionPort');
+ assert.notEqual(index, -1);
+ return Number(args[index + 1]);
+}
+
+class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess {
+ stdin = new PassThrough();
+ stdout = new PassThrough();
+ stderr = new PassThrough();
+ killed = false;
+ onKill: (() => void) | undefined;
+
+ kill(): boolean {
+ this.killed = true;
+ this.onKill?.();
+ return true;
+ }
+
+ emitExit(code: number | null, signal: NodeJS.Signals | null): void {
+ this.emit('exit', code, signal);
+ this.emit('close', code, signal);
+ }
+}
diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts
index 45fed04ae..d62a3d64d 100644
--- a/src/platforms/android/__tests__/snapshot-helper.test.ts
+++ b/src/platforms/android/__tests__/snapshot-helper.test.ts
@@ -77,6 +77,7 @@ test('parseAndroidSnapshotHelperOutput reconstructs XML chunks and metadata', ()
nodeCount: 1,
truncated: false,
elapsedMs: 42,
+ transport: 'instrumentation',
});
});
@@ -589,6 +590,88 @@ test('captureAndroidSnapshotWithHelper uses injected adb executor', async () =>
assert.equal(result.metadata.maxNodes, 100);
});
+test('captureAndroidSnapshotWithHelper can read output file when chunks are disabled', async () => {
+ const adbCalls: string[][] = [];
+ const outputPath = '/sdcard/Download/agent-device-snapshot.xml';
+ const adb: AndroidAdbExecutor = async (args) => {
+ adbCalls.push(args);
+ if (args[0] === 'shell' && args[1] === 'sh') {
+ assert.equal(args.at(-1), outputPath);
+ return {
+ exitCode: 0,
+ stdout: '',
+ stderr: '',
+ };
+ }
+ return {
+ exitCode: 0,
+ stdout: helperOutput({
+ chunks: [],
+ result: {
+ ok: 'true',
+ outputFormat: 'uiautomator-xml',
+ waitForIdleTimeoutMs: '10',
+ waitForIdleQuietMs: '5',
+ timeoutMs: '9000',
+ maxDepth: '64',
+ maxNodes: '100',
+ },
+ }),
+ stderr: '',
+ };
+ };
+
+ const result = await captureAndroidSnapshotWithHelper({
+ adb,
+ waitForIdleTimeoutMs: 10,
+ waitForIdleQuietMs: 5,
+ timeoutMs: 9000,
+ maxDepth: 64,
+ maxNodes: 100,
+ outputPath,
+ emitChunks: false,
+ });
+
+ assert.deepEqual(adbCalls[0], [
+ 'shell',
+ 'am',
+ 'instrument',
+ '-w',
+ '-e',
+ 'waitForIdleTimeoutMs',
+ '10',
+ '-e',
+ 'waitForIdleQuietMs',
+ '5',
+ '-e',
+ 'timeoutMs',
+ '9000',
+ '-e',
+ 'maxDepth',
+ '64',
+ '-e',
+ 'maxNodes',
+ '100',
+ '-e',
+ 'outputPath',
+ outputPath,
+ '-e',
+ 'emitChunks',
+ 'false',
+ 'com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation',
+ ]);
+ assert.deepEqual(adbCalls[1], [
+ 'shell',
+ 'sh',
+ '-c',
+ 'cat "$1"; status=$?; rm -f "$1"; exit "$status"',
+ 'agent-device-snapshot-helper-output',
+ outputPath,
+ ]);
+ assert.equal(result.xml, '');
+ assert.equal(result.metadata.maxNodes, 100);
+});
+
test('captureAndroidSnapshotWithHelper gives adb command overhead beyond helper timeout', async () => {
let commandTimeoutMs: number | undefined;
await captureAndroidSnapshotWithHelper({
@@ -653,16 +736,13 @@ test('captureAndroidSnapshotWithHelper reads helper output file when instrumenta
stderr: '',
};
}
- if (args[0] === 'shell' && args[1] === 'cat') {
+ if (args[0] === 'shell' && args[1] === 'sh') {
return {
exitCode: 0,
stdout: '',
stderr: '',
};
}
- if (args[0] === 'shell' && args[1] === 'rm') {
- return { exitCode: 0, stdout: '', stderr: '' };
- }
throw new Error(`unexpected args: ${args.join(' ')}`);
},
outputPath: '/sdcard/Android/data/com.callstack.agentdevice.snapshothelper/files/test.xml',
@@ -672,7 +752,10 @@ test('captureAndroidSnapshotWithHelper reads helper output file when instrumenta
assert.equal(result.metadata.outputFormat, 'uiautomator-xml');
assert.deepEqual(calls.at(1), [
'shell',
- 'cat',
+ 'sh',
+ '-c',
+ 'cat "$1"; status=$?; rm -f "$1"; exit "$status"',
+ 'agent-device-snapshot-helper-output',
'/sdcard/Android/data/com.callstack.agentdevice.snapshothelper/files/test.xml',
]);
});
diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts
index a592add21..236a032db 100644
--- a/src/platforms/android/__tests__/snapshot.test.ts
+++ b/src/platforms/android/__tests__/snapshot.test.ts
@@ -367,6 +367,8 @@ test('snapshotAndroid forwards alert-style helper idle timeout override', async
assert.ok(instrumentArgs);
assert.equal(instrumentArgs[instrumentArgs.indexOf('waitForIdleTimeoutMs') + 1], '0');
+ assert.equal(instrumentArgs.includes('outputPath'), false);
+ assert.equal(instrumentArgs.includes('emitChunks'), false);
});
test('snapshotAndroid emits helper phase diagnostics', async () => {
@@ -651,25 +653,21 @@ test('snapshotAndroid skips stock fallback after killed helper instrumentation',
assert.equal(stockAttempted, false);
});
-test('snapshotAndroid skips stock fallback after unparseable helper output', async () => {
- let stockAttempted = false;
+test('snapshotAndroid falls back to stock dump after unparseable helper output', async () => {
+ const stockXml =
+ '';
const helperAdb = createHelperAdb({
instrument: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
- stock: async () => {
- stockAttempted = true;
- throw new Error('stock fallback should not run');
- },
+ stock: async () => ({ exitCode: 0, stdout: stockXml, stderr: '' }),
});
- await assert.rejects(
- () => snapshotAndroidWithHelper(helperAdb),
- (error) => {
- assert.match((error as Error).message, /Android snapshot helper output could not be parsed/);
- assert.match((error as Error).message, /Stock UIAutomator fallback was skipped/);
- return true;
- },
+ const result = await snapshotAndroidWithHelper(helperAdb);
+
+ assert.equal(result.androidSnapshot.backend, 'uiautomator-dump');
+ assert.match(
+ result.androidSnapshot.fallbackReason ?? '',
+ /Android snapshot helper output could not be parsed/,
);
- assert.equal(stockAttempted, false);
});
test('snapshotAndroid falls back to stock dump after helper adb timeout', async () => {
@@ -1041,6 +1039,74 @@ test('snapshotAndroid skips hidden content hints when disabled', async () => {
);
});
+test('snapshotAndroid uses helper scroll action hints without activity dump', async () => {
+ const xml = `
+
+
+
+
+
+
+
+
+`;
+
+ mockRunCmd.mockImplementation(async (_cmd, args) => {
+ if (isAndroidSdkVersionCommand(args)) {
+ return { exitCode: 0, stdout: '35', stderr: '' };
+ }
+ if (args.includes('exec-out')) {
+ return { exitCode: 0, stdout: xml, stderr: '' };
+ }
+ if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) {
+ throw new Error('dumpsys activity top should not run when helper action hints exist');
+ }
+ throw new Error(`unexpected args: ${args.join(' ')}`);
+ });
+
+ const result = await snapshotAndroid(device);
+ const scrollArea = result.nodes.find((node) => node.type === 'android.widget.ScrollView');
+
+ assert.ok(scrollArea);
+ assert.equal(scrollArea.hiddenContentBelow, true);
+ assert.equal(scrollArea.hiddenContentAbove, undefined);
+});
+
+test('snapshotAndroid does not convert horizontal helper scroll action to vertical hints', async () => {
+ const xml = `
+
+
+
+
+
+
+
+
+`;
+
+ mockRunCmd.mockImplementation(async (_cmd, args) => {
+ if (isAndroidSdkVersionCommand(args)) {
+ return { exitCode: 0, stdout: '35', stderr: '' };
+ }
+ if (args.includes('exec-out')) {
+ return { exitCode: 0, stdout: xml, stderr: '' };
+ }
+ if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) {
+ throw new Error('dumpsys activity top should not run when helper action hints exist');
+ }
+ throw new Error(`unexpected args: ${args.join(' ')}`);
+ });
+
+ const result = await snapshotAndroid(device);
+ const scrollArea = result.nodes.find(
+ (node) => node.type === 'android.widget.HorizontalScrollView',
+ );
+
+ assert.ok(scrollArea);
+ assert.equal(scrollArea.hiddenContentBelow, undefined);
+ assert.equal(scrollArea.hiddenContentAbove, undefined);
+});
+
test('snapshotAndroid derives hidden content hints for interactive snapshots from shared visibility semantics', async () => {
const xml = `
@@ -1072,6 +1138,39 @@ test('snapshotAndroid derives hidden content hints for interactive snapshots fro
assert.equal(scrollArea?.hiddenContentBelow, true);
});
+test('snapshotAndroid omits zero-area interactive nodes from interactive snapshots', async () => {
+ const xml = `
+
+
+
+
+
+
+
+
+
+`;
+
+ mockAndroidSnapshotXml(xml);
+
+ const result = await snapshotAndroid(device, { interactiveOnly: true });
+
+ assert.equal(
+ result.nodes.some((node) => node.label === 'Visible action'),
+ true,
+ );
+ assert.equal(
+ result.nodes.some((node) => node.label === 'Collapsed action'),
+ false,
+ );
+ assert.equal(
+ result.nodes.some(
+ (node) => node.rect !== undefined && (node.rect.width <= 0 || node.rect.height <= 0),
+ ),
+ false,
+ );
+});
+
test('snapshotAndroid preserves bottomed-out hidden-above hints in interactive snapshots from a single aligned block', async () => {
const xml = `
diff --git a/src/platforms/android/device-input-state.ts b/src/platforms/android/device-input-state.ts
index cee9b45c8..3f57675b4 100644
--- a/src/platforms/android/device-input-state.ts
+++ b/src/platforms/android/device-input-state.ts
@@ -25,6 +25,28 @@ const ANDROID_TEXT_VARIATION_VISIBLE_PASSWORD = 0x00000090;
const ANDROID_KEYBOARD_DISMISS_MAX_ATTEMPTS = 2;
const ANDROID_KEYBOARD_DISMISS_RETRY_DELAY_MS = 120;
const ANDROID_KEYCODE_ESCAPE = '111';
+const ANDROID_KEYBOARD_VISIBILITY_KEYS = [
+ 'mInputShown',
+ 'mIsInputViewShown',
+ 'isInputViewShown',
+ 'mDecorViewVisible',
+ 'mWindowVisible',
+ 'mInShowWindow',
+];
+const ANDROID_KEYBOARD_CLASS_BY_INPUT_CLASS = new Map([
+ [ANDROID_INPUT_TYPE_CLASS_NUMBER, 'number'],
+ [ANDROID_INPUT_TYPE_CLASS_PHONE, 'phone'],
+ [ANDROID_INPUT_TYPE_CLASS_DATETIME, 'datetime'],
+]);
+const ANDROID_EMAIL_TEXT_VARIATIONS = new Set([
+ ANDROID_TEXT_VARIATION_EMAIL_ADDRESS,
+ ANDROID_TEXT_VARIATION_WEB_EMAIL_ADDRESS,
+]);
+const ANDROID_PASSWORD_TEXT_VARIATIONS = new Set([
+ ANDROID_TEXT_VARIATION_PASSWORD,
+ ANDROID_TEXT_VARIATION_WEB_PASSWORD,
+ ANDROID_TEXT_VARIATION_VISIBLE_PASSWORD,
+]);
type AndroidKeyboardType =
| 'text'
@@ -122,22 +144,8 @@ export async function dismissAndroidKeyboardWithAdb(
}
function parseAndroidKeyboardState(stdout: string): AndroidKeyboardState {
- const visibility = parseAndroidKeyboardVisibility(stdout);
- let visible = visibility ?? false;
- if (visibility === null) {
- const imeWindowVisibility = stdout.match(/\bmImeWindowVis=0x([0-9a-fA-F]+)\b/);
- if (imeWindowVisibility?.[1]) {
- const flags = Number.parseInt(imeWindowVisibility[1], 16);
- if (!Number.isNaN(flags)) {
- visible = (flags & 0x1) !== 0;
- }
- }
- }
-
- const inputTypeMatches = Array.from(stdout.matchAll(/\binputType=0x([0-9a-fA-F]+)\b/gi));
- const lastInputType =
- inputTypeMatches.length > 0 ? inputTypeMatches[inputTypeMatches.length - 1]?.[1] : undefined;
- const inputType = lastInputType ? `0x${lastInputType.toLowerCase()}` : undefined;
+ const visible = parseAndroidKeyboardVisibility(stdout) ?? parseLegacyImeWindowVisibility(stdout);
+ const inputType = parseLastAndroidInputType(stdout);
const focusedPackage = parseLastDumpsysValue(stdout, /\bpackageName=([A-Za-z0-9_.]+)\b/g);
const focusedResourceId = parseLastDumpsysValue(
stdout,
@@ -149,23 +157,10 @@ function parseAndroidKeyboardState(stdout: string): AndroidKeyboardState {
focusedResourceId,
inputMethodPackage,
);
- if (
- !inputMethodPackage &&
- (isFallbackAndroidInputMethodPackage(focusedPackage) ||
- isFallbackAndroidInputMethodResource(focusedResourceId))
- ) {
- emitDiagnostic({
- level: 'warn',
- phase: 'android_input_ownership_fallback',
- data: {
- focusedPackage,
- focusedResourceId,
- },
- });
- }
+ emitAndroidInputOwnershipFallbackDiagnostic(focusedPackage, focusedResourceId, inputMethodPackage);
return {
- visible,
+ visible: visible ?? false,
inputType,
type: inputType ? classifyAndroidKeyboardType(inputType) : undefined,
inputMethodPackage,
@@ -175,6 +170,22 @@ function parseAndroidKeyboardState(stdout: string): AndroidKeyboardState {
};
}
+function parseLegacyImeWindowVisibility(stdout: string): boolean | null {
+ const imeWindowVisibility = stdout.match(/\bmImeWindowVis=0x([0-9a-fA-F]+)\b/);
+ const rawFlags = imeWindowVisibility?.[1];
+ if (!rawFlags) return null;
+
+ const flags = Number.parseInt(rawFlags, 16);
+ if (Number.isNaN(flags)) return null;
+
+ return (flags & 0x1) !== 0;
+}
+
+function parseLastAndroidInputType(stdout: string): string | undefined {
+ const value = parseLastDumpsysValue(stdout, /\binputType=0x([0-9a-fA-F]+)\b/gi);
+ return value ? `0x${value.toLowerCase()}` : undefined;
+}
+
function parseLastDumpsysValue(stdout: string, pattern: RegExp): string | undefined {
let value: string | undefined;
for (const match of stdout.matchAll(pattern)) {
@@ -183,45 +194,90 @@ function parseLastDumpsysValue(stdout: string, pattern: RegExp): string | undefi
return value;
}
+function emitAndroidInputOwnershipFallbackDiagnostic(
+ focusedPackage: string | undefined,
+ focusedResourceId: string | undefined,
+ inputMethodPackage: string | undefined,
+): void {
+ if (inputMethodPackage) return;
+ if (
+ !isFallbackAndroidInputMethodPackage(focusedPackage) &&
+ !isFallbackAndroidInputMethodResource(focusedResourceId)
+ ) {
+ return;
+ }
+
+ emitDiagnostic({
+ level: 'warn',
+ phase: 'android_input_ownership_fallback',
+ data: {
+ focusedPackage,
+ focusedResourceId,
+ },
+ });
+}
+
function parseAndroidKeyboardVisibility(stdout: string): boolean | null {
+ const latestByKey = parseLatestBooleanDumpsysValues(stdout, ANDROID_KEYBOARD_VISIBILITY_KEYS);
+ return resolveAndroidKeyboardVisibility(latestByKey);
+}
+
+function parseLatestBooleanDumpsysValues(stdout: string, keys: string[]): Map {
const latestByKey = new Map();
- const pattern = /\b(mInputShown|mIsInputViewShown|isInputViewShown)=([a-zA-Z]+)\b/g;
+ const pattern = new RegExp(`\\b(${keys.join('|')})=([a-zA-Z]+)\\b`, 'g');
for (const match of stdout.matchAll(pattern)) {
const key = match[1];
const value = match[2]?.toLowerCase();
if (!key || (value !== 'true' && value !== 'false')) continue;
latestByKey.set(key, value === 'true');
}
+ return latestByKey;
+}
+
+function resolveAndroidKeyboardVisibility(latestByKey: Map): boolean | null {
if (latestByKey.size === 0) return null;
- for (const visible of latestByKey.values()) {
- if (visible) return true;
+
+ const windowVisible = firstDefinedBoolean(latestByKey, [
+ 'mWindowVisible',
+ 'mDecorViewVisible',
+ 'mInShowWindow',
+ ]);
+ if (windowVisible !== undefined) return windowVisible;
+
+ const inputShown = latestByKey.get('mInputShown');
+ if (inputShown !== undefined) return inputShown;
+
+ const inputViewShown = firstDefinedBoolean(latestByKey, [
+ 'mIsInputViewShown',
+ 'isInputViewShown',
+ ]);
+ return inputViewShown ?? null;
+}
+
+function firstDefinedBoolean(
+ values: Map,
+ keys: readonly string[],
+): boolean | undefined {
+ for (const key of keys) {
+ const value = values.get(key);
+ if (value !== undefined) return value;
}
- return false;
+ return undefined;
}
function classifyAndroidKeyboardType(inputType: string): AndroidKeyboardType {
const parsed = Number.parseInt(inputType.replace(/^0x/i, ''), 16);
if (Number.isNaN(parsed)) return 'unknown';
+
const inputClass = parsed & ANDROID_INPUT_TYPE_CLASS_MASK;
- if (inputClass === ANDROID_INPUT_TYPE_CLASS_NUMBER) return 'number';
- if (inputClass === ANDROID_INPUT_TYPE_CLASS_PHONE) return 'phone';
- if (inputClass === ANDROID_INPUT_TYPE_CLASS_DATETIME) return 'datetime';
+ const knownInputClass = ANDROID_KEYBOARD_CLASS_BY_INPUT_CLASS.get(inputClass);
+ if (knownInputClass) return knownInputClass;
if (inputClass !== ANDROID_INPUT_TYPE_CLASS_TEXT) return 'unknown';
const variation = parsed & ANDROID_INPUT_TYPE_VARIATION_MASK;
- if (
- variation === ANDROID_TEXT_VARIATION_EMAIL_ADDRESS ||
- variation === ANDROID_TEXT_VARIATION_WEB_EMAIL_ADDRESS
- ) {
- return 'email';
- }
- if (
- variation === ANDROID_TEXT_VARIATION_PASSWORD ||
- variation === ANDROID_TEXT_VARIATION_WEB_PASSWORD ||
- variation === ANDROID_TEXT_VARIATION_VISIBLE_PASSWORD
- ) {
- return 'password';
- }
+ if (ANDROID_EMAIL_TEXT_VARIATIONS.has(variation)) return 'email';
+ if (ANDROID_PASSWORD_TEXT_VARIATIONS.has(variation)) return 'password';
+
return 'text';
}
diff --git a/src/platforms/android/fill-verification.ts b/src/platforms/android/fill-verification.ts
index a26ea899b..b8bae828a 100644
--- a/src/platforms/android/fill-verification.ts
+++ b/src/platforms/android/fill-verification.ts
@@ -11,7 +11,7 @@ import {
import { sleep } from './adb.ts';
import { getAndroidKeyboardState } from './device-input-state.ts';
import { isAndroidInputMethodOwnedNode } from './input-ownership.ts';
-import { dumpUiHierarchy } from './snapshot.ts';
+import { captureAndroidUiHierarchyXml } from './snapshot.ts';
import { androidUiNodes, type AndroidUiNodeMetadata } from './ui-hierarchy.ts';
export type AndroidFillVerificationNode = FillDiagnosticNode & {
@@ -90,7 +90,7 @@ export async function readAndroidTextAtPoint(
x: number,
y: number,
): Promise {
- return readAndroidTextAtPointInHierarchy(await dumpUiHierarchy(device), x, y);
+ return readAndroidTextAtPointInHierarchy(await captureAndroidUiHierarchyXml(device), x, y);
}
export function verifyAndroidFilledTextInHierarchy(
@@ -154,7 +154,13 @@ async function inspectAndroidFilledText(
expected: string,
context: AndroidFillVerificationContext,
): Promise {
- return verifyAndroidFilledTextInHierarchy(await dumpUiHierarchy(device), x, y, expected, context);
+ return verifyAndroidFilledTextInHierarchy(
+ await captureAndroidUiHierarchyXml(device),
+ x,
+ y,
+ expected,
+ context,
+ );
}
function inspectAndroidTextAtPointInHierarchy(
diff --git a/src/platforms/android/input-actions.ts b/src/platforms/android/input-actions.ts
index 4a43fd173..1ec70efa3 100644
--- a/src/platforms/android/input-actions.ts
+++ b/src/platforms/android/input-actions.ts
@@ -6,6 +6,8 @@ import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll-
import { runAndroidAdb, sleep } from './adb.ts';
import { resolveAndroidTextInjector } from './adb-executor.ts';
import { getAndroidKeyboardState, type AndroidKeyboardState } from './device-input-state.ts';
+import { captureAndroidUiHierarchyXml } from './snapshot.ts';
+import { androidUiNodes } from './ui-hierarchy.ts';
import {
androidFillFailureDetails,
androidFillFailureMessage,
@@ -206,7 +208,7 @@ export async function scrollAndroid(
direction: ScrollDirection,
options?: { amount?: number; pixels?: number },
): Promise> {
- const size = await getAndroidScreenSize(device);
+ const size = await getAndroidGestureViewportSize(device);
const plan = buildScrollGesturePlan({
direction,
amount: options?.amount,
@@ -290,6 +292,38 @@ export async function getAndroidScreenSize(
return { width: Number(match[1]), height: Number(match[2]) };
}
+async function getAndroidGestureViewportSize(
+ device: DeviceInfo,
+): Promise<{ width: number; height: number }> {
+ try {
+ const xml = await captureAndroidUiHierarchyXml(device);
+ const viewport = largestAndroidUiNodeRect(xml);
+ if (viewport) return viewport;
+ } catch (error) {
+ emitDiagnostic({
+ level: 'warn',
+ phase: 'android_gesture_viewport_probe_failed',
+ data: {
+ error: error instanceof Error ? error.message : String(error),
+ },
+ });
+ }
+ return await getAndroidScreenSize(device);
+}
+
+function largestAndroidUiNodeRect(xml: string): { width: number; height: number } | null {
+ let largest: { width: number; height: number; area: number } | null = null;
+ for (const node of androidUiNodes(xml)) {
+ const rect = node.rect;
+ if (!rect || rect.width <= 0 || rect.height <= 0) continue;
+ const area = rect.width * rect.height;
+ if (!largest || area > largest.area) {
+ largest = { width: rect.x + rect.width, height: rect.y + rect.height, area };
+ }
+ }
+ return largest ? { width: largest.width, height: largest.height } : null;
+}
+
const ANDROID_INPUT_TEXT_CHUNK_SIZE = 8;
async function typeAndroidShell(
diff --git a/src/platforms/android/snapshot-helper-capture.ts b/src/platforms/android/snapshot-helper-capture.ts
index 272102052..78231e895 100644
--- a/src/platforms/android/snapshot-helper-capture.ts
+++ b/src/platforms/android/snapshot-helper-capture.ts
@@ -30,7 +30,7 @@ type AndroidInstrumentationRecordState = {
currentResult: Record | null;
};
-type AndroidSnapshotHelperResolvedCaptureOptions = {
+export type AndroidSnapshotHelperResolvedCaptureOptions = {
waitForIdleTimeoutMs: number;
waitForIdleQuietMs: number;
timeoutMs: number;
@@ -40,6 +40,12 @@ type AndroidSnapshotHelperResolvedCaptureOptions = {
packageName: string;
runner: string;
outputPath?: string;
+ emitChunks?: boolean;
+};
+
+type AndroidSnapshotHelperReadResult = {
+ output: AndroidSnapshotHelperOutput;
+ cleanupDone: boolean;
};
export async function captureAndroidSnapshotWithHelper(
@@ -50,8 +56,10 @@ export async function captureAndroidSnapshotWithHelper(
allowFailure: true,
timeoutMs: resolved.commandTimeoutMs,
});
- const output = await readAndroidSnapshotHelperOutput(options, resolved, result);
- if (resolved.outputPath) await removeHelperOutputFile(options.adb, resolved.outputPath);
+ const { output, cleanupDone } = await readAndroidSnapshotHelperOutput(options, resolved, result);
+ if (resolved.outputPath && !cleanupDone) {
+ await removeHelperOutputFile(options.adb, resolved.outputPath);
+ }
if (result.exitCode !== 0) {
throw new AppError('COMMAND_FAILED', 'Android snapshot helper failed', {
stdout: result.stdout,
@@ -63,7 +71,7 @@ export async function captureAndroidSnapshotWithHelper(
return output;
}
-function resolveAndroidSnapshotHelperCaptureOptions(
+export function resolveAndroidSnapshotHelperCaptureOptions(
options: AndroidSnapshotHelperCaptureOptions,
): AndroidSnapshotHelperResolvedCaptureOptions {
const timeoutMs = withDefault(options.timeoutMs, 8_000);
@@ -87,6 +95,7 @@ function resolveAndroidSnapshotHelperCaptureOptions(
packageName,
runner: withDefault(options.instrumentationRunner, `${packageName}/.SnapshotInstrumentation`),
...(options.outputPath ? { outputPath: options.outputPath } : {}),
+ ...(options.emitChunks !== undefined ? { emitChunks: options.emitChunks } : {}),
};
}
@@ -94,7 +103,7 @@ function withDefault(value: T | undefined, fallback: T): T {
return value === undefined ? fallback : value;
}
-function buildAndroidSnapshotHelperArgs(
+export function buildAndroidSnapshotHelperArgs(
options: AndroidSnapshotHelperResolvedCaptureOptions,
): string[] {
return [
@@ -117,7 +126,10 @@ function buildAndroidSnapshotHelperArgs(
'-e',
'maxNodes',
String(options.maxNodes),
+ // Default production snapshots use instrumentation status chunks. File output remains a
+ // fallback/testing transport for devices where status output cannot carry the payload.
...(options.outputPath ? ['-e', 'outputPath', options.outputPath] : []),
+ ...(options.emitChunks !== undefined ? ['-e', 'emitChunks', String(options.emitChunks)] : []),
options.runner,
];
}
@@ -126,10 +138,13 @@ async function readAndroidSnapshotHelperOutput(
options: AndroidSnapshotHelperCaptureOptions,
resolved: AndroidSnapshotHelperResolvedCaptureOptions,
result: Awaited>,
-): Promise {
+): Promise {
try {
// The helper can report structured ok=false details even when am exits non-zero.
- return parseAndroidSnapshotHelperOutput(`${result.stdout}\n${result.stderr}`);
+ return {
+ output: parseAndroidSnapshotHelperOutput(`${result.stdout}\n${result.stderr}`),
+ cleanupDone: false,
+ };
} catch (error) {
return await readFallbackHelperOutputOrThrow(options, resolved, result, error);
}
@@ -140,18 +155,10 @@ async function readFallbackHelperOutputOrThrow(
resolved: AndroidSnapshotHelperResolvedCaptureOptions,
result: Awaited>,
error: unknown,
-): Promise {
- if (resolved.outputPath) {
- const fileOutput = await readHelperOutputFile(options.adb, resolved.outputPath, {
- waitForIdleTimeoutMs: resolved.waitForIdleTimeoutMs,
- waitForIdleQuietMs: resolved.waitForIdleQuietMs,
- timeoutMs: resolved.timeoutMs,
- maxDepth: resolved.maxDepth,
- maxNodes: resolved.maxNodes,
- });
- if (fileOutput) return fileOutput;
- }
+): Promise {
if (error instanceof AppError && result.exitCode !== 0 && error.details?.helper) throw error;
+ const fileOutput = await readFallbackHelperOutputFile(options, resolved, result);
+ if (fileOutput) return { output: fileOutput, cleanupDone: true };
throw new AppError(
'COMMAND_FAILED',
result.exitCode === 0
@@ -166,36 +173,91 @@ async function readFallbackHelperOutputOrThrow(
);
}
+async function readFallbackHelperOutputFile(
+ options: AndroidSnapshotHelperCaptureOptions,
+ resolved: AndroidSnapshotHelperResolvedCaptureOptions,
+ result: Awaited>,
+): Promise {
+ if (result.exitCode !== 0 || !resolved.outputPath) return undefined;
+ return await readHelperOutputFile(
+ options.adb,
+ resolved.outputPath,
+ readHelperMetadataFromInstrumentationOutput(`${result.stdout}\n${result.stderr}`) ??
+ fallbackAndroidSnapshotHelperMetadata(resolved),
+ );
+}
+
+function fallbackAndroidSnapshotHelperMetadata(
+ resolved: AndroidSnapshotHelperResolvedCaptureOptions,
+): AndroidSnapshotHelperMetadata {
+ return {
+ outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT,
+ waitForIdleTimeoutMs: resolved.waitForIdleTimeoutMs,
+ waitForIdleQuietMs: resolved.waitForIdleQuietMs,
+ timeoutMs: resolved.timeoutMs,
+ maxDepth: resolved.maxDepth,
+ maxNodes: resolved.maxNodes,
+ transport: 'instrumentation',
+ };
+}
+
async function readHelperOutputFile(
adb: AndroidSnapshotHelperCaptureOptions['adb'],
outputPath: string,
- metadata: Omit,
+ metadata: AndroidSnapshotHelperMetadata,
): Promise {
- const result = await adb(['shell', 'cat', outputPath], {
- allowFailure: true,
- timeoutMs: 5_000,
- });
- await removeHelperOutputFile(adb, outputPath);
+ let result: Awaited>;
+ try {
+ result = await adb(buildReadAndRemoveHelperOutputArgs(outputPath), {
+ allowFailure: true,
+ timeoutMs: 5_000,
+ });
+ } catch {
+ return undefined;
+ }
if (result.exitCode !== 0) return undefined;
const xml = result.stdout.trim();
if (!xml.includes('')) return undefined;
return {
xml,
- metadata: {
- ...metadata,
- outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT,
- },
+ metadata,
};
}
+function buildReadAndRemoveHelperOutputArgs(outputPath: string): string[] {
+ return [
+ 'shell',
+ 'sh',
+ '-c',
+ 'cat "$1"; status=$?; rm -f "$1"; exit "$status"',
+ 'agent-device-snapshot-helper-output',
+ outputPath,
+ ];
+}
+
+function readHelperMetadataFromInstrumentationOutput(
+ output: string,
+): AndroidSnapshotHelperMetadata | null {
+ try {
+ const records = parseInstrumentationRecords(output);
+ return readHelperMetadata(readFinalHelperResult(records.results));
+ } catch {
+ return null;
+ }
+}
+
async function removeHelperOutputFile(
adb: AndroidSnapshotHelperCaptureOptions['adb'],
outputPath: string,
): Promise {
- await adb(['shell', 'rm', '-f', outputPath], {
- allowFailure: true,
- timeoutMs: 5_000,
- });
+ try {
+ await adb(['shell', 'rm', '-f', outputPath], {
+ allowFailure: true,
+ timeoutMs: 5_000,
+ });
+ } catch {
+ // Cleanup is best-effort; snapshot capture should not fail because a stale temp file survived.
+ }
}
export function parseAndroidSnapshotHelperOutput(output: string): AndroidSnapshotHelperOutput {
@@ -205,7 +267,7 @@ export function parseAndroidSnapshotHelperOutput(output: string): AndroidSnapsho
return {
xml,
- metadata: readHelperMetadata(finalResult),
+ metadata: { ...readHelperMetadata(finalResult), transport: 'instrumentation' },
};
}
@@ -442,7 +504,9 @@ function readKeyValue(line: string, target: Record): void {
target[line.slice(0, separator)] = line.slice(separator + 1);
}
-function readOptionalNumber(value: string | undefined): number | undefined {
+export function readAndroidSnapshotHelperMetadataNumber(
+ value: string | undefined,
+): number | undefined {
if (value === undefined) {
return undefined;
}
@@ -450,7 +514,9 @@ function readOptionalNumber(value: string | undefined): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
-function readOptionalBoolean(value: string | undefined): boolean | undefined {
+export function readAndroidSnapshotHelperMetadataBoolean(
+ value: string | undefined,
+): boolean | undefined {
if (value === 'true') {
return true;
}
@@ -459,3 +525,6 @@ function readOptionalBoolean(value: string | undefined): boolean | undefined {
}
return undefined;
}
+
+const readOptionalNumber = readAndroidSnapshotHelperMetadataNumber;
+const readOptionalBoolean = readAndroidSnapshotHelperMetadataBoolean;
diff --git a/src/platforms/android/snapshot-helper-session.ts b/src/platforms/android/snapshot-helper-session.ts
new file mode 100644
index 000000000..cb136932b
--- /dev/null
+++ b/src/platforms/android/snapshot-helper-session.ts
@@ -0,0 +1,413 @@
+import net from 'node:net';
+import type { AndroidAdbProcess } from './adb-executor.ts';
+import { AppError } from '../../utils/errors.ts';
+import { emitDiagnostic } from '../../utils/diagnostics.ts';
+import {
+ ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT,
+ ANDROID_SNAPSHOT_HELPER_PROTOCOL,
+ type AndroidAdbExecutor,
+ type AndroidSnapshotHelperCaptureOptions,
+ type AndroidSnapshotHelperMetadata,
+ type AndroidSnapshotHelperOutput,
+} from './snapshot-helper-types.ts';
+import {
+ buildAndroidSnapshotHelperArgs,
+ readAndroidSnapshotHelperMetadataBoolean,
+ readAndroidSnapshotHelperMetadataNumber,
+ resolveAndroidSnapshotHelperCaptureOptions,
+ type AndroidSnapshotHelperResolvedCaptureOptions,
+} from './snapshot-helper-capture.ts';
+
+const SESSION_READY_TIMEOUT_MS = 10_000;
+const SESSION_STOP_TIMEOUT_MS = 1_000;
+const FORWARD_TIMEOUT_MS = 5_000;
+
+type AndroidSnapshotHelperSession = {
+ identity: string;
+ deviceKey: string;
+ port: number;
+ adb: AndroidAdbExecutor;
+ process: AndroidAdbProcess;
+ startedAtMs: number;
+ capturedCount: number;
+};
+
+const sessions = new Map();
+
+export async function captureAndroidSnapshotWithHelperSession(
+ options: AndroidSnapshotHelperCaptureOptions,
+): Promise {
+ if (!isAndroidSnapshotHelperSessionEnabled() || !options.adbProvider?.spawn) {
+ return undefined;
+ }
+ const resolved = resolveAndroidSnapshotHelperCaptureOptions(options);
+ const deviceKey = options.deviceKey ?? 'android:default';
+ const identity = createSessionIdentity(deviceKey, resolved, options);
+ let session = sessions.get(deviceKey);
+ if (session && session.identity !== identity) {
+ await stopAndroidSnapshotHelperSession(deviceKey);
+ session = undefined;
+ }
+ if (!session) {
+ session = await startAndroidSnapshotHelperSession({
+ deviceKey,
+ identity,
+ options,
+ resolved,
+ });
+ }
+ try {
+ const reused = session.capturedCount > 0;
+ const output = await requestSessionSnapshot(session, resolved);
+ session.capturedCount += 1;
+ return {
+ xml: output.xml,
+ metadata: {
+ ...output.metadata,
+ transport: 'persistent-session',
+ sessionReused: reused,
+ },
+ };
+ } catch (error) {
+ await stopAndroidSnapshotHelperSession(deviceKey);
+ throw error;
+ }
+}
+
+export async function stopAndroidSnapshotHelperSession(deviceKey: string): Promise {
+ const session = sessions.get(deviceKey);
+ if (!session) return;
+ sessions.delete(deviceKey);
+ try {
+ await sendSessionCommand(session, `quit ${Date.now()}`, SESSION_STOP_TIMEOUT_MS);
+ } catch {
+ // The process may already be gone; adb forward cleanup and kill below are still enough.
+ }
+ try {
+ await session.process.kill('SIGTERM');
+ } catch {
+ // Best effort. A completed instrumentation process can reject/ignore kill.
+ }
+ try {
+ await removeForward(session);
+ } catch {
+ // Stale forwards are harmless and the next start overwrites its chosen local port.
+ }
+ emitDiagnostic({
+ phase: 'android_snapshot_helper_session_stop',
+ data: {
+ deviceKey,
+ port: session.port,
+ capturedCount: session.capturedCount,
+ lifetimeMs: Date.now() - session.startedAtMs,
+ },
+ });
+}
+
+export async function resetAndroidSnapshotHelperSessions(): Promise {
+ await Promise.all([...sessions.keys()].map((deviceKey) => stopAndroidSnapshotHelperSession(deviceKey)));
+}
+
+async function startAndroidSnapshotHelperSession(params: {
+ deviceKey: string;
+ identity: string;
+ options: AndroidSnapshotHelperCaptureOptions;
+ resolved: AndroidSnapshotHelperResolvedCaptureOptions;
+}): Promise {
+ const port = await getFreePort();
+ await params.options.adb(['forward', `tcp:${port}`, `tcp:${port}`], {
+ allowFailure: false,
+ timeoutMs: FORWARD_TIMEOUT_MS,
+ });
+ const args = buildAndroidSnapshotHelperArgs({
+ ...params.resolved,
+ outputPath: undefined,
+ emitChunks: false,
+ });
+ const runner = args[args.length - 1];
+ if (!runner) {
+ throw new AppError('INVALID_ARGS', 'Android snapshot helper runner was not resolved');
+ }
+ const sessionArgs = [
+ ...args.slice(0, -1),
+ '-e',
+ 'sessionPort',
+ String(port),
+ runner,
+ ];
+ const process = params.options.adbProvider!.spawn!(sessionArgs, {
+ allowFailure: true,
+ captureOutput: false,
+ });
+ const session: AndroidSnapshotHelperSession = {
+ identity: params.identity,
+ deviceKey: params.deviceKey,
+ port,
+ adb: params.options.adb,
+ process,
+ startedAtMs: Date.now(),
+ capturedCount: 0,
+ };
+ try {
+ await waitForSessionReady(process, SESSION_READY_TIMEOUT_MS);
+ sessions.set(params.deviceKey, session);
+ emitDiagnostic({
+ phase: 'android_snapshot_helper_session_ready',
+ data: {
+ deviceKey: params.deviceKey,
+ port,
+ packageName: params.resolved.packageName,
+ runner: params.resolved.runner,
+ },
+ });
+ return session;
+ } catch (error) {
+ await removeForward(session);
+ try {
+ process.kill('SIGTERM');
+ } catch {
+ // Best effort after startup failure.
+ }
+ throw error;
+ }
+}
+
+function waitForSessionReady(process: AndroidAdbProcess, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ let output = '';
+ const timer = setTimeout(() => {
+ reject(
+ new AppError('COMMAND_FAILED', 'Android snapshot helper session did not become ready', {
+ output,
+ timeoutMs,
+ }),
+ );
+ }, timeoutMs);
+ const onData = (chunk: Buffer | string) => {
+ output += chunk.toString();
+ if (
+ output.includes(`agentDeviceProtocol=${ANDROID_SNAPSHOT_HELPER_PROTOCOL}`) &&
+ output.includes('sessionReady=true')
+ ) {
+ clearTimeout(timer);
+ resolve();
+ }
+ };
+ process.stdout?.on('data', onData);
+ process.stderr?.on('data', onData);
+ process.once('exit', (code, signal) => {
+ clearTimeout(timer);
+ reject(
+ new AppError('COMMAND_FAILED', 'Android snapshot helper session exited before ready', {
+ output,
+ exitCode: code,
+ signal,
+ }),
+ );
+ });
+ process.on('error', (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+ });
+}
+
+async function requestSessionSnapshot(
+ session: AndroidSnapshotHelperSession,
+ resolved: AndroidSnapshotHelperResolvedCaptureOptions,
+): Promise {
+ const requestId = `snapshot-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const timeoutMs = Math.max(resolved.timeoutMs + 2_000, 3_000);
+ const response = await sendSessionCommand(session, `snapshot ${requestId}`, timeoutMs);
+ return parseSessionSnapshotResponse(response, requestId);
+}
+
+function sendSessionCommand(
+ session: AndroidSnapshotHelperSession,
+ command: string,
+ timeoutMs: number,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const socket = net.connect({ host: '127.0.0.1', port: session.port });
+ const chunks: Buffer[] = [];
+ const timer = setTimeout(() => {
+ socket.destroy();
+ reject(
+ new AppError('COMMAND_FAILED', 'Android snapshot helper session request timed out', {
+ command,
+ timeoutMs,
+ port: session.port,
+ }),
+ );
+ }, timeoutMs);
+ socket.on('connect', () => {
+ socket.write(`${command}\n`);
+ });
+ socket.on('data', (chunk) => {
+ chunks.push(Buffer.from(chunk));
+ });
+ socket.on('error', (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+ socket.on('close', () => {
+ clearTimeout(timer);
+ resolve(Buffer.concat(chunks).toString('utf8'));
+ });
+ });
+}
+
+function parseSessionSnapshotResponse(
+ response: string,
+ requestId: string,
+): AndroidSnapshotHelperOutput {
+ const { headers, xml } = splitSessionResponse(response);
+ validateSessionHeaders(headers, requestId);
+ validateSessionXml(headers, xml);
+ return { xml, metadata: readSessionMetadata(headers) };
+}
+
+function splitSessionResponse(response: string): { headers: Record; xml: string } {
+ const separator = response.indexOf('\n\n');
+ if (separator < 0) {
+ throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned malformed output', {
+ response,
+ });
+ }
+ return {
+ headers: parseSessionHeaders(response.slice(0, separator)),
+ xml: response.slice(separator + 2),
+ };
+}
+
+function validateSessionHeaders(headers: Record, requestId: string): void {
+ if (headers.agentDeviceProtocol !== ANDROID_SNAPSHOT_HELPER_PROTOCOL) {
+ throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned wrong protocol', {
+ headers,
+ });
+ }
+ if (headers.outputFormat !== ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT) {
+ throw new AppError(
+ 'COMMAND_FAILED',
+ 'Android snapshot helper session returned wrong output format',
+ { headers },
+ );
+ }
+ if (headers.requestId !== requestId) {
+ throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned stale output', {
+ headers,
+ requestId,
+ });
+ }
+ if (headers.ok !== 'true') {
+ throw new AppError(
+ 'COMMAND_FAILED',
+ headers.message || headers.errorType || 'Android snapshot helper session returned an error',
+ { helper: headers },
+ );
+ }
+}
+
+function validateSessionXml(headers: Record, xml: string): void {
+ const byteLength = readAndroidSnapshotHelperMetadataNumber(headers.byteLength);
+ if (byteLength !== undefined && Buffer.byteLength(xml, 'utf8') !== byteLength) {
+ throw new AppError(
+ 'COMMAND_FAILED',
+ 'Android snapshot helper session returned truncated XML',
+ { headers, actualByteLength: Buffer.byteLength(xml, 'utf8') },
+ );
+ }
+ if (!xml.includes('')) {
+ throw new AppError('COMMAND_FAILED', 'Android snapshot helper session did not return XML', {
+ headers,
+ xml,
+ });
+ }
+}
+
+function parseSessionHeaders(headerText: string): Record {
+ const headers: Record = {};
+ for (const line of headerText.split(/\r?\n/)) {
+ const separator = line.indexOf('=');
+ if (separator < 0) continue;
+ headers[line.slice(0, separator)] = line.slice(separator + 1);
+ }
+ return headers;
+}
+
+function readSessionMetadata(headers: Record): AndroidSnapshotHelperMetadata {
+ return {
+ helperApiVersion: headers.helperApiVersion,
+ outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT,
+ waitForIdleTimeoutMs: readAndroidSnapshotHelperMetadataNumber(headers.waitForIdleTimeoutMs),
+ waitForIdleQuietMs: readAndroidSnapshotHelperMetadataNumber(headers.waitForIdleQuietMs),
+ timeoutMs: readAndroidSnapshotHelperMetadataNumber(headers.timeoutMs),
+ maxDepth: readAndroidSnapshotHelperMetadataNumber(headers.maxDepth),
+ maxNodes: readAndroidSnapshotHelperMetadataNumber(headers.maxNodes),
+ rootPresent: readAndroidSnapshotHelperMetadataBoolean(headers.rootPresent),
+ captureMode:
+ headers.captureMode === 'interactive-windows' || headers.captureMode === 'active-window'
+ ? headers.captureMode
+ : undefined,
+ windowCount: readAndroidSnapshotHelperMetadataNumber(headers.windowCount),
+ nodeCount: readAndroidSnapshotHelperMetadataNumber(headers.nodeCount),
+ truncated: readAndroidSnapshotHelperMetadataBoolean(headers.truncated),
+ elapsedMs: readAndroidSnapshotHelperMetadataNumber(headers.elapsedMs),
+ };
+}
+
+async function removeForward(session: AndroidSnapshotHelperSession): Promise {
+ await session.process.stdin?.end();
+ await session.process.stdout?.destroy();
+ await session.process.stderr?.destroy();
+ await sessionForwardRemove(session);
+}
+
+async function sessionForwardRemove(session: AndroidSnapshotHelperSession): Promise {
+ await session.adb(['forward', '--remove', `tcp:${session.port}`], {
+ allowFailure: true,
+ timeoutMs: FORWARD_TIMEOUT_MS,
+ });
+}
+
+function createSessionIdentity(
+ deviceKey: string,
+ resolved: AndroidSnapshotHelperResolvedCaptureOptions,
+ options: AndroidSnapshotHelperCaptureOptions,
+): string {
+ const identity = JSON.stringify({
+ deviceKey,
+ packageName: resolved.packageName,
+ runner: resolved.runner,
+ helperVersion: options.helperVersion,
+ helperVersionCode: options.helperVersionCode,
+ waitForIdleTimeoutMs: resolved.waitForIdleTimeoutMs,
+ waitForIdleQuietMs: resolved.waitForIdleQuietMs,
+ timeoutMs: resolved.timeoutMs,
+ maxDepth: resolved.maxDepth,
+ maxNodes: resolved.maxNodes,
+ });
+ return identity;
+}
+
+function isAndroidSnapshotHelperSessionEnabled(): boolean {
+ const value = process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION;
+ return value === undefined || !/^(0|false|no|off)$/i.test(value);
+}
+
+function getFreePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.unref();
+ server.on('error', reject);
+ server.listen(0, '127.0.0.1', () => {
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ server.close(() => reject(new Error('Failed to allocate a local TCP port')));
+ return;
+ }
+ const port = address.port;
+ server.close(() => resolve(port));
+ });
+ });
+}
diff --git a/src/platforms/android/snapshot-helper-types.ts b/src/platforms/android/snapshot-helper-types.ts
index c06430cd6..2c356c753 100644
--- a/src/platforms/android/snapshot-helper-types.ts
+++ b/src/platforms/android/snapshot-helper-types.ts
@@ -1,5 +1,5 @@
import type { RawSnapshotNode } from '../../utils/snapshot.ts';
-import type { AndroidAdbExecutor } from './adb-executor.ts';
+import type { AndroidAdbExecutor, AndroidAdbProvider } from './adb-executor.ts';
import type { AndroidSnapshotAnalysis } from './ui-hierarchy.ts';
import type { AndroidSnapshotBackendMetadata } from './snapshot-types.ts';
@@ -9,8 +9,8 @@ export const ANDROID_SNAPSHOT_HELPER_RUNNER =
'com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation';
export const ANDROID_SNAPSHOT_HELPER_PROTOCOL = 'android-snapshot-helper-v1';
export const ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT = 'uiautomator-xml';
-export const ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS = 500;
-export const ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_QUIET_MS = 100;
+export const ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS = 25;
+export const ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_QUIET_MS = 25;
export const ANDROID_SNAPSHOT_HELPER_COMMAND_OVERHEAD_MS = 5_000;
export type { AndroidAdbExecutor } from './adb-executor.ts';
@@ -54,6 +54,10 @@ export type AndroidSnapshotHelperInstallResult = {
export type AndroidSnapshotHelperCaptureOptions = {
adb: AndroidAdbExecutor;
+ adbProvider?: AndroidAdbProvider;
+ deviceKey?: string;
+ helperVersion?: string;
+ helperVersionCode?: number;
packageName?: string;
instrumentationRunner?: string;
waitForIdleTimeoutMs?: number;
@@ -63,6 +67,7 @@ export type AndroidSnapshotHelperCaptureOptions = {
maxDepth?: number;
maxNodes?: number;
outputPath?: string;
+ emitChunks?: boolean;
};
export type AndroidSnapshotHelperMetadata = {
@@ -79,6 +84,8 @@ export type AndroidSnapshotHelperMetadata = {
nodeCount?: number;
truncated?: boolean;
elapsedMs?: number;
+ transport?: 'instrumentation' | 'persistent-session';
+ sessionReused?: boolean;
};
export type AndroidSnapshotHelperOutput = {
diff --git a/src/platforms/android/snapshot-helper.ts b/src/platforms/android/snapshot-helper.ts
index 25ba73ba6..ec24da245 100644
--- a/src/platforms/android/snapshot-helper.ts
+++ b/src/platforms/android/snapshot-helper.ts
@@ -8,6 +8,11 @@ export {
parseAndroidSnapshotHelperOutput,
parseAndroidSnapshotHelperXml,
} from './snapshot-helper-capture.ts';
+export {
+ captureAndroidSnapshotWithHelperSession,
+ resetAndroidSnapshotHelperSessions,
+ stopAndroidSnapshotHelperSession,
+} from './snapshot-helper-session.ts';
export {
ensureAndroidSnapshotHelper,
forgetAndroidSnapshotHelperInstall,
diff --git a/src/platforms/android/snapshot-types.ts b/src/platforms/android/snapshot-types.ts
index 809c5f936..0465cf26f 100644
--- a/src/platforms/android/snapshot-types.ts
+++ b/src/platforms/android/snapshot-types.ts
@@ -4,6 +4,8 @@ export type AndroidSnapshotBackendMetadata = {
backend: 'android-helper' | 'uiautomator-dump';
helperVersion?: string;
helperApiVersion?: string;
+ helperTransport?: 'instrumentation' | 'persistent-session';
+ helperSessionReused?: boolean;
fallbackReason?: string;
installReason?: 'missing' | 'outdated' | 'forced' | 'current' | 'skipped';
waitForIdleTimeoutMs?: number;
diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts
index 8663444d7..698f27904 100644
--- a/src/platforms/android/snapshot.ts
+++ b/src/platforms/android/snapshot.ts
@@ -21,14 +21,20 @@ import {
type AndroidSnapshotAnalysis,
type AndroidUiHierarchy,
} from './ui-hierarchy.ts';
-import { resolveAndroidAdbExecutor, resolveAndroidAdbProvider } from './adb-executor.ts';
+import {
+ resolveAndroidAdbExecutor,
+ resolveAndroidAdbProvider,
+ type AndroidAdbProvider,
+} from './adb-executor.ts';
import { deriveAndroidScrollableContentHints } from './scroll-hints.ts';
import {
captureAndroidSnapshotWithHelper,
+ captureAndroidSnapshotWithHelperSession,
ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS,
ensureAndroidSnapshotHelper,
forgetAndroidSnapshotHelperInstall,
parseAndroidSnapshotHelperManifest,
+ stopAndroidSnapshotHelperSession,
type AndroidAdbExecutor,
type AndroidSnapshotHelperArtifact,
type AndroidSnapshotHelperInstallPolicy,
@@ -54,14 +60,22 @@ const RETRYABLE_ADB_STDERR_PATTERNS = [
'no such file or directory',
] as const;
-type AndroidSnapshotOptions = SnapshotOptions & {
+export type AndroidSnapshotOptions = SnapshotOptions & {
helperArtifact?: AndroidSnapshotHelperArtifact;
helperInstallPolicy?: AndroidSnapshotHelperInstallPolicy;
- helperAdb?: AndroidAdbExecutor;
+ helperAdb?: AndroidAdbExecutor | AndroidAdbProvider;
helperWaitForIdleTimeoutMs?: number;
includeHiddenContentHints?: boolean;
};
+export async function captureAndroidUiHierarchyXml(
+ device: DeviceInfo,
+ options: AndroidSnapshotOptions = {},
+): Promise {
+ const adb = resolveAndroidAdbProvider(device, options.helperAdb).exec;
+ return (await captureAndroidUiHierarchy(device, options, adb)).xml;
+}
+
export async function snapshotAndroid(
device: DeviceInfo,
options: AndroidSnapshotOptions = {},
@@ -71,43 +85,75 @@ export async function snapshotAndroid(
analysis: AndroidSnapshotAnalysis;
androidSnapshot: AndroidSnapshotBackendMetadata;
}> {
- const adb = resolveAndroidAdbExecutor(device, options.helperAdb);
+ const adb = resolveAndroidAdbProvider(device, options.helperAdb).exec;
const capture = await captureAndroidUiHierarchy(device, options, adb);
const xml = capture.xml;
const includeHiddenContentHints = options.includeHiddenContentHints !== false;
if (!options.interactiveOnly) {
const parsed = parseUiHierarchy(xml, ANDROID_SNAPSHOT_MAX_NODES, options);
if (includeHiddenContentHints) {
- const nativeHints = await deriveScrollableContentHintsIfNeeded(device, parsed.nodes, adb);
+ const nativeHints = await deriveScrollableContentHintsIfNeeded(device, parsed.nodes, xml, adb);
applyHiddenContentHintsToNodes(nativeHints, parsed.nodes);
}
return { ...parsed, androidSnapshot: capture.metadata };
}
const tree = parseUiHierarchyTree(xml);
- const fullSnapshot = buildUiHierarchySnapshot(tree, ANDROID_SNAPSHOT_MAX_NODES, {
- ...options,
- interactiveOnly: false,
- });
const interactiveSnapshot = buildUiHierarchySnapshot(tree, ANDROID_SNAPSHOT_MAX_NODES, options);
if (includeHiddenContentHints) {
- const nativeHints = await deriveScrollableContentHintsIfNeeded(device, fullSnapshot.nodes, adb);
- applyHiddenContentHintsToInteractiveNodes(nativeHints, fullSnapshot, interactiveSnapshot);
- if (nativeHints.size === 0) {
- const presentationHints = deriveMobileSnapshotHiddenContentHints(
- attachRefs(fullSnapshot.nodes),
- );
- applyHiddenContentHintsToInteractiveNodes(
- presentationHints,
- fullSnapshot,
- interactiveSnapshot,
- );
- }
+ await applyHiddenContentHintsToInteractiveSnapshot({
+ device,
+ options,
+ tree,
+ xml,
+ adb,
+ interactiveSnapshot,
+ });
}
const { sourceNodes: _sourceNodes, ...snapshot } = interactiveSnapshot;
return { ...snapshot, androidSnapshot: capture.metadata };
}
+async function applyHiddenContentHintsToInteractiveSnapshot(params: {
+ device: DeviceInfo;
+ options: AndroidSnapshotOptions;
+ tree: AndroidUiHierarchy;
+ xml: string;
+ adb: AndroidAdbExecutor;
+ interactiveSnapshot: AndroidBuiltSnapshot;
+}): Promise {
+ if (
+ collectExistingHiddenContentHints(params.interactiveSnapshot.nodes).size > 0 ||
+ hasAndroidScrollActionAttributes(params.xml)
+ ) {
+ return;
+ }
+
+ const fullSnapshot = buildUiHierarchySnapshot(params.tree, ANDROID_SNAPSHOT_MAX_NODES, {
+ ...params.options,
+ interactiveOnly: false,
+ });
+ const nativeHints = await deriveScrollableContentHintsIfNeeded(
+ params.device,
+ fullSnapshot.nodes,
+ params.xml,
+ params.adb,
+ );
+ applyHiddenContentHintsToInteractiveNodes(
+ nativeHints,
+ fullSnapshot,
+ params.interactiveSnapshot,
+ );
+ if (nativeHints.size === 0) {
+ const presentationHints = deriveMobileSnapshotHiddenContentHints(attachRefs(fullSnapshot.nodes));
+ applyHiddenContentHintsToInteractiveNodes(
+ presentationHints,
+ fullSnapshot,
+ params.interactiveSnapshot,
+ );
+ }
+}
+
async function captureAndroidUiHierarchy(
device: DeviceInfo,
options: AndroidSnapshotOptions,
@@ -136,15 +182,25 @@ async function captureAndroidUiHierarchyWithHelper(
artifact: AndroidSnapshotHelperArtifact,
): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> {
const helperDeviceKey = getAndroidSnapshotHelperDeviceKey(device);
+ const adbProvider = resolveAndroidAdbProvider(device, options.helperAdb);
try {
const install = await installAndroidSnapshotHelper(
- device,
options,
adb,
+ adbProvider,
+ artifact,
+ helperDeviceKey,
+ );
+ if (install.installed) {
+ await stopAndroidSnapshotHelperSession(helperDeviceKey);
+ }
+ const capture = await captureAndroidUiHierarchyFromHelper(
+ options,
+ adb,
+ adbProvider,
artifact,
helperDeviceKey,
);
- const capture = await captureAndroidUiHierarchyFromHelper(options, adb, artifact);
return formatAndroidHelperCaptureResult(capture, artifact, install.reason);
} catch (error) {
return await recoverAndroidHelperCaptureFailure({
@@ -158,9 +214,9 @@ async function captureAndroidUiHierarchyWithHelper(
}
async function installAndroidSnapshotHelper(
- device: DeviceInfo,
options: AndroidSnapshotOptions,
adb: AndroidAdbExecutor,
+ adbProvider: AndroidAdbProvider,
artifact: AndroidSnapshotHelperArtifact,
deviceKey: string,
): Promise {
@@ -169,7 +225,7 @@ async function installAndroidSnapshotHelper(
async () =>
await ensureAndroidSnapshotHelper({
adb,
- adbProvider: resolveAndroidAdbProvider(device, options.helperAdb),
+ adbProvider,
artifact,
deviceKey,
installPolicy: options.helperInstallPolicy,
@@ -197,20 +253,44 @@ async function installAndroidSnapshotHelper(
async function captureAndroidUiHierarchyFromHelper(
options: AndroidSnapshotOptions,
adb: AndroidAdbExecutor,
+ adbProvider: AndroidAdbProvider,
artifact: AndroidSnapshotHelperArtifact,
+ deviceKey: string,
): Promise {
- return await withDiagnosticTimer(
- 'android_snapshot_helper_capture',
- async () =>
- await captureAndroidSnapshotWithHelper({
- adb,
+ const captureOptions = {
+ adb,
+ adbProvider,
+ deviceKey,
+ helperVersion: artifact.manifest.version,
+ helperVersionCode: artifact.manifest.versionCode,
+ packageName: artifact.manifest.packageName,
+ instrumentationRunner: artifact.manifest.instrumentationRunner,
+ waitForIdleTimeoutMs:
+ options.helperWaitForIdleTimeoutMs ?? ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS,
+ timeoutMs: HELPER_CAPTURE_TIMEOUT_MS,
+ commandTimeoutMs: HELPER_COMMAND_TIMEOUT_MS,
+ };
+ try {
+ const sessionCapture = await withDiagnosticTimer(
+ 'android_snapshot_helper_session_capture',
+ async () => await captureAndroidSnapshotWithHelperSession(captureOptions),
+ {
packageName: artifact.manifest.packageName,
- instrumentationRunner: artifact.manifest.instrumentationRunner,
- waitForIdleTimeoutMs:
- options.helperWaitForIdleTimeoutMs ?? ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS,
+ version: artifact.manifest.version,
timeoutMs: HELPER_CAPTURE_TIMEOUT_MS,
- commandTimeoutMs: HELPER_COMMAND_TIMEOUT_MS,
- }),
+ },
+ );
+ if (sessionCapture) return sessionCapture;
+ } catch (error) {
+ emitDiagnostic({
+ level: 'warn',
+ phase: 'android_snapshot_helper_session_fallback',
+ data: { reason: normalizeError(error).message },
+ });
+ }
+ return await withDiagnosticTimer(
+ 'android_snapshot_helper_capture',
+ async () => await captureAndroidSnapshotWithHelper(captureOptions),
{
packageName: artifact.manifest.packageName,
version: artifact.manifest.version,
@@ -231,6 +311,8 @@ function formatAndroidHelperCaptureResult(
backend: 'android-helper',
helperVersion: artifact.manifest.version,
helperApiVersion: capture.metadata.helperApiVersion,
+ helperTransport: capture.metadata.transport,
+ helperSessionReused: capture.metadata.sessionReused,
installReason,
waitForIdleTimeoutMs: capture.metadata.waitForIdleTimeoutMs,
waitForIdleQuietMs: capture.metadata.waitForIdleQuietMs,
@@ -262,6 +344,7 @@ async function recoverAndroidHelperCaptureFailure(params: {
phase: 'android_snapshot_helper_fallback',
data: { reason: fallbackReason },
});
+ await stopAndroidSnapshotHelperSession(params.helperDeviceKey);
forgetAndroidSnapshotHelperInstall({
deviceKey: params.helperDeviceKey,
packageName: params.artifact.manifest.packageName,
@@ -287,8 +370,7 @@ function formatAndroidSnapshotHelperBusyError(error: unknown): AppError | undefi
const normalized = normalizeError(error);
if (
!isStructuredHelperTimeout(normalized.details?.helper, normalized.message) &&
- !isKilledHelperInstrumentationFailure(normalized) &&
- !isUnsafeStockFallbackHelperReason(normalized.message)
+ !isKilledHelperInstrumentationFailure(normalized)
) {
return undefined;
}
@@ -316,10 +398,6 @@ function isKilledHelperInstrumentationFailure(error: {
);
}
-function isUnsafeStockFallbackHelperReason(reason: string): boolean {
- return /Android snapshot helper output could not be parsed/.test(reason);
-}
-
function readHelperMessage(helper: unknown): string | undefined {
if (!helper || typeof helper !== 'object' || !('message' in helper)) return undefined;
const message = String(helper.message).trim();
@@ -422,11 +500,16 @@ function getAndroidSnapshotHelperDeviceKey(device: DeviceInfo): string {
async function deriveScrollableContentHintsIfNeeded(
device: DeviceInfo,
nodes: RawSnapshotNode[],
+ xml: string,
adb?: AndroidAdbExecutor,
): Promise