diff --git a/android-snapshot-helper/README.md b/android-snapshot-helper/README.md index b30404b02..f0cf0e2b3 100644 --- a/android-snapshot-helper/README.md +++ b/android-snapshot-helper/README.md @@ -43,6 +43,9 @@ The `-t` install flag is required because the helper is a test-only instrumentat Devices or providers that block test-package installs must allow this package before helper capture can run. +`waitForIdleTimeoutMs` defaults to `500`, which is a maximum wait, not a fixed sleep. Direct helper +invocations can pass `0` when immediate capture during ongoing animation is preferred. + ## Output Contract The APK emits instrumentation status records using diff --git a/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java b/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java index 7f2863b7e..c9e0d626b 100644 --- a/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java +++ b/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java @@ -30,8 +30,8 @@ public final class SnapshotInstrumentation extends Instrumentation { private static final String OUTPUT_FORMAT = "uiautomator-xml"; private static final String HELPER_API_VERSION = "1"; private static final int CHUNK_SIZE = 2 * 1024; - // Match the host defaults: long enough to avoid mid-transition RN snapshots, but still bounded - // below the stock uiautomator idle wait so busy apps do not stall every capture. + // Match the host default: bounded wait for microinteraction reliability without the stock + // uiautomator idle tax. Direct callers can pass 0 when immediate capture is preferred. private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 500; private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 100; private static final long DEFAULT_TIMEOUT_MS = 8_000; diff --git a/src/compat/maestro/runtime-assertions.ts b/src/compat/maestro/runtime-assertions.ts index 2545c7e82..61f881323 100644 --- a/src/compat/maestro/runtime-assertions.ts +++ b/src/compat/maestro/runtime-assertions.ts @@ -78,6 +78,7 @@ async function invokeNativeMaestroVisibleWaitWithSnapshotFallback( const nativeStartedAt = Date.now(); const nativeResponse = await runNativeVisibleWait(params, args, nativeWaitQuery); if (nativeResponse.ok) { + rememberMaestroVisibleContext(params.scope, args.selector); return visibleAssertionResponse( { ok: true, diff --git a/src/daemon/__tests__/post-gesture-stabilization.test.ts b/src/daemon/__tests__/post-gesture-stabilization.test.ts index 2a8328123..fd7c0ac54 100644 --- a/src/daemon/__tests__/post-gesture-stabilization.test.ts +++ b/src/daemon/__tests__/post-gesture-stabilization.test.ts @@ -62,6 +62,32 @@ test('capturePostGestureStabilizedSnapshot retries until rects stop moving', asy assert.equal(session.postGestureStabilization, undefined); }); +test('capturePostGestureStabilizedSnapshot samples again after a slow first capture', async () => { + vi.useFakeTimers(); + const session = makeSession('android'); + markPostGestureStabilization(session, 'click', [], { postGestureStabilization: true }); + let captures = 0; + + const promise = capturePostGestureStabilizedSnapshot({ + session, + capture: async () => { + captures += 1; + if (captures === 1) { + await new Promise((resolve) => setTimeout(resolve, 1_600)); + } + return makeSnapshot(100); + }, + }); + + await vi.advanceTimersByTimeAsync(1_600); + await vi.advanceTimersByTimeAsync(200); + const snapshot = await promise; + + assert.equal(captures, 2); + assert.equal(snapshot.nodes[1]?.rect?.y, 100); + assert.equal(session.postGestureStabilization, undefined); +}); + function makeSession(platform: 'ios' | 'android' = 'ios'): SessionState { return { name: platform, diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index 6a4287fbb..a979d4f84 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -1050,6 +1050,77 @@ test('runReplayScriptFile captures a fresh Maestro snapshot for tapOn after asse ); }); +test('runReplayScriptFile scopes duplicate tap targets after native Maestro assertVisible', async () => { + const { response, calls } = await runReplayFixture({ + label: 'maestro-native-assert-context-duplicate-tap', + script: ['appId: demo.app', '---', '- assertVisible: Albums', '- tapOn: Push article', ''].join( + '\n', + ), + flags: { replayBackend: 'maestro', platform: 'android' }, + invoke: async (req) => { + if (req.command === 'wait') { + return { ok: true, data: { matched: true } }; + } + if (req.command === 'snapshot') { + return { + ok: true, + data: { + nodes: [ + { + index: 1, + depth: 1, + type: 'android.widget.ScrollView', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + index: 2, + depth: 2, + parentIndex: 1, + type: 'android.widget.TextView', + label: 'Albums', + rect: { x: 24, y: 120, width: 120, height: 40 }, + }, + { + index: 3, + depth: 2, + parentIndex: 1, + type: 'android.widget.TextView', + label: 'Push article', + rect: { x: 32, y: 220, width: 160, height: 44 }, + }, + { + index: 10, + depth: 1, + type: 'android.widget.ScrollView', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + index: 11, + depth: 2, + parentIndex: 10, + type: 'android.widget.TextView', + label: 'Push article', + rect: { x: 32, y: 520, width: 160, height: 44 }, + }, + ], + }, + }; + } + return { ok: true, data: {} }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['wait', ['Albums', '17000']], + ['snapshot', []], + ['click', ['112', '242']], + ], + ); +}); + test('runReplayScriptFile treats absent Maestro assertNotVisible targets as passing', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ diff --git a/src/daemon/post-gesture-stabilization.ts b/src/daemon/post-gesture-stabilization.ts index 7e8207a0f..e19658845 100644 --- a/src/daemon/post-gesture-stabilization.ts +++ b/src/daemon/post-gesture-stabilization.ts @@ -10,6 +10,7 @@ import type { SessionState } from './types.ts'; const STABILIZATION_DEADLINE_MS = 1_500; const STABILIZATION_INTERVAL_MS = 200; +const STABILIZATION_MIN_ATTEMPTS = 2; export function markPostGestureStabilization( session: SessionState, @@ -58,7 +59,10 @@ export async function capturePostGestureStabilizedResult(params: { let previous = params.initial ?? (await capture()); let previousSignature = buildInteractionSurfaceSignature(params.readSnapshot(previous).nodes); - while (Date.now() - startedAt < STABILIZATION_DEADLINE_MS) { + while ( + attempts < STABILIZATION_MIN_ATTEMPTS || + Date.now() - startedAt < STABILIZATION_DEADLINE_MS + ) { await sleep(STABILIZATION_INTERVAL_MS); attempts += 1; const current = await capture(); diff --git a/src/platforms/android/snapshot-helper-types.ts b/src/platforms/android/snapshot-helper-types.ts index b1f4667e6..aa813eb2c 100644 --- a/src/platforms/android/snapshot-helper-types.ts +++ b/src/platforms/android/snapshot-helper-types.ts @@ -18,6 +18,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'; +// Keep common snapshots biased toward post-microinteraction reliability. The +// value is a max wait; callers that need immediate capture can explicitly pass 0. 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_COMMAND_OVERHEAD_MS = 5_000;