Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions android-snapshot-helper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/compat/maestro/runtime-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions src/daemon/__tests__/post-gesture-stabilization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions src/daemon/handlers/__tests__/session-replay-vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 5 additions & 1 deletion src/daemon/post-gesture-stabilization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,7 +59,10 @@ export async function capturePostGestureStabilizedResult<T>(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();
Expand Down
2 changes: 2 additions & 0 deletions src/platforms/android/snapshot-helper-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading