From 90be4d332b641a4980f33a7c51f76a303dddd51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 08:14:16 -0700 Subject: [PATCH 01/12] fix: stabilize maestro post-gesture snapshots --- .../__tests__/runtime-interactions.test.ts | 17 +- src/compat/maestro/runtime-interactions.ts | 10 + src/core/dispatch-context.ts | 1 + .../interaction-outcome-policy.test.ts | 3 +- .../post-gesture-stabilization.test.ts | 16 ++ .../__tests__/session-replay-vars.test.ts | 8 + src/daemon/handlers/__tests__/session.test.ts | 1 + .../__tests__/snapshot-handler.test.ts | 205 +++++++++++++++--- src/daemon/handlers/interaction-common.ts | 2 + src/daemon/handlers/session.ts | 1 + src/daemon/handlers/snapshot-capture.ts | 69 +++++- src/daemon/interaction-outcome-policy.ts | 8 +- src/daemon/post-gesture-stabilization.ts | 41 +++- src/daemon/request-generic-dispatch.ts | 2 +- .../ios/__tests__/runner-session.test.ts | 17 ++ src/platforms/ios/runner-provider.ts | 1 + src/platforms/ios/runner-session.ts | 59 +++-- .../android-test-suite.test.ts | 2 +- 18 files changed, 390 insertions(+), 73 deletions(-) diff --git a/src/compat/maestro/__tests__/runtime-interactions.test.ts b/src/compat/maestro/__tests__/runtime-interactions.test.ts index a8bddc4b9..8f00546ec 100644 --- a/src/compat/maestro/__tests__/runtime-interactions.test.ts +++ b/src/compat/maestro/__tests__/runtime-interactions.test.ts @@ -11,13 +11,14 @@ test('invokeMaestroTapOn resolves mutating taps from the current snapshot', asyn const selector = 'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"'; - const { response, clicks, snapshots } = await runTapOn(selector, () => + const { response, clicks, clickFlags, snapshots } = await runTapOn(selector, () => currentBreadcrumbSnapshot(), ); expect(response.ok).toBe(true); expect(snapshots).toBe(1); expect(clicks).toEqual([['86', '89']]); + expect(clickFlags[0]?.postGestureStabilization).toBe(true); }); test('invokeMaestroTapOn uses optimized interactive snapshots by default', async () => { @@ -125,6 +126,7 @@ test('invokeMaestroTapOn clicks explicit React Native overlay controls directly' test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gesture presets', async () => { const gestures: string[][] = []; + const gestureFlags: Array = []; const response = await invokeMaestroSwipeScreen({ baseReq: { token: 'test', @@ -135,6 +137,7 @@ test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gest invoke: async (req: DaemonRequest): Promise => { if (req.command === 'gesture') { gestures.push(req.positionals ?? []); + gestureFlags.push(req.flags); return { ok: true, data: {} }; } return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; @@ -143,6 +146,7 @@ test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gest expect(response.ok).toBe(true); expect(gestures).toEqual([['swipe', 'left', '300']]); + expect(gestureFlags[0]?.postGestureStabilization).toBeUndefined(); }); test('invokeMaestroSwipeScreen mirrors horizontal directional swipe presets', async () => { @@ -169,6 +173,7 @@ test('invokeMaestroSwipeScreen mirrors horizontal directional swipe presets', as test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async () => { const swipes: string[][] = []; + const swipeFlags: Array = []; const response = await invokeMaestroSwipeScreen({ baseReq: { token: 'test', @@ -182,6 +187,7 @@ test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async ( } if (req.command === 'swipe') { swipes.push(req.positionals ?? []); + swipeFlags.push(req.flags); return { ok: true, data: {} }; } return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; @@ -190,6 +196,7 @@ test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async ( expect(response.ok).toBe(true); expect(swipes).toEqual([['200', '600', '200', '280', '300']]); + expect(swipeFlags[0]?.postGestureStabilization).toBe(true); }); test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the content lane', async () => { @@ -219,6 +226,7 @@ test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the test('invokeMaestroTapPointPercent shares percentage point geometry without clamping', async () => { const clicks: string[][] = []; + const clickFlags: Array = []; const response = await invokeMaestroTapPointPercent({ baseReq: { token: 'test', @@ -232,6 +240,7 @@ test('invokeMaestroTapPointPercent shares percentage point geometry without clam } if (req.command === 'click') { clicks.push(req.positionals ?? []); + clickFlags.push(req.flags); return { ok: true, data: {} }; } return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; @@ -240,6 +249,7 @@ test('invokeMaestroTapPointPercent shares percentage point geometry without clam expect(response.ok).toBe(true); expect(clicks).toEqual([['500', '-80']]); + expect(clickFlags[0]?.postGestureStabilization).toBe(true); }); function currentBreadcrumbSnapshot(): SnapshotState { @@ -277,10 +287,12 @@ async function runTapOn( response: DaemonResponse; commands: string[]; clicks: string[][]; + clickFlags: Array; snapshots: number; }> { const commands: string[] = []; const clicks: string[][] = []; + const clickFlags: Array = []; let snapshots = 0; const response = await invokeMaestroTapOn({ baseReq: { @@ -297,12 +309,13 @@ async function runTapOn( } if (req.command === 'click') { clicks.push(req.positionals ?? []); + clickFlags.push(req.flags); return { ok: true, data: {} }; } return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; }, }); - return { response, commands, clicks, snapshots }; + return { response, commands, clicks, clickFlags, snapshots }; } function fullScreenSnapshot(width: number, height: number): SnapshotState { diff --git a/src/compat/maestro/runtime-interactions.ts b/src/compat/maestro/runtime-interactions.ts index 0eb6dc2da..e063a044f 100644 --- a/src/compat/maestro/runtime-interactions.ts +++ b/src/compat/maestro/runtime-interactions.ts @@ -147,6 +147,10 @@ export async function invokeMaestroTapPointPercent(params: { ...params.baseReq, command: 'click', positionals: [String(point.x), String(point.y)], + flags: { + ...params.baseReq.flags, + postGestureStabilization: true, + }, }); } @@ -235,6 +239,10 @@ async function invokeSwipeGesture( String(swipe.end.y), ...(durationMs ? [durationMs] : []), ], + flags: { + ...params.baseReq.flags, + postGestureStabilization: true, + }, }); } @@ -479,6 +487,7 @@ async function clickMaestroSnapshotTarget( flags: { ...params.baseReq.flags, interactionOutcome: { retryOnNoChange: true }, + postGestureStabilization: true, }, }); if (response.ok) clearMaestroVisibleContext(params.scope); @@ -502,6 +511,7 @@ async function invokeMaestroFuzzyTapOn( ...params.baseReq.flags, findFirst: true, interactionOutcome: { retryOnNoChange: true }, + postGestureStabilization: true, }, }); emitDiagnostic({ diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index b99e4d0b4..f044b3920 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -20,6 +20,7 @@ export type CommandFlags = Omit & { }; launchArgs?: string[]; maestro?: MaestroRuntimeFlags; + postGestureStabilization?: boolean; replayBackend?: string; }; diff --git a/src/daemon/__tests__/interaction-outcome-policy.test.ts b/src/daemon/__tests__/interaction-outcome-policy.test.ts index 4816eb0f6..408a487d3 100644 --- a/src/daemon/__tests__/interaction-outcome-policy.test.ts +++ b/src/daemon/__tests__/interaction-outcome-policy.test.ts @@ -84,11 +84,12 @@ test('markPendingInteractionOutcome stores retry state only for explicit retry f assert.equal(longPressSession.pendingInteractionOutcome, undefined); }); -test('stripInternalInteractionOutcomeFlags removes internal retry controls', () => { +test('stripInternalInteractionOutcomeFlags removes internal interaction controls', () => { assert.deepEqual( stripInternalInteractionOutcomeFlags({ platform: 'ios', interactionOutcome: { retryOnNoChange: true }, + postGestureStabilization: true, }), { platform: 'ios' }, ); diff --git a/src/daemon/__tests__/post-gesture-stabilization.test.ts b/src/daemon/__tests__/post-gesture-stabilization.test.ts index 77270fb78..2a8328123 100644 --- a/src/daemon/__tests__/post-gesture-stabilization.test.ts +++ b/src/daemon/__tests__/post-gesture-stabilization.test.ts @@ -28,6 +28,22 @@ test('markPostGestureStabilization marks Android swipe sessions', () => { assert.equal(session.postGestureStabilization?.action, 'swipe'); }); +test('markPostGestureStabilization marks gesture swipe sessions', () => { + const session = makeSession('android'); + + markPostGestureStabilization(session, 'gesture', ['swipe', 'left']); + + assert.equal(session.postGestureStabilization?.action, 'gesture'); +}); + +test('markPostGestureStabilization ignores non-swipe gesture sessions', () => { + const session = makeSession('android'); + + markPostGestureStabilization(session, 'gesture', ['pinch', 'in']); + + assert.equal(session.postGestureStabilization, undefined); +}); + test('capturePostGestureStabilizedSnapshot retries until rects stop moving', async () => { vi.useFakeTimers(); const session = makeSession(); diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index 3a9e482fc..533cd3282 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -1889,9 +1889,17 @@ test('runReplayScriptFile runs Maestro runFlow.when.visible commands when presen assert.deepEqual(calls.find((call) => call.command === 'click')?.flags?.interactionOutcome, { retryOnNoChange: true, }); + assert.equal( + calls.find((call) => call.command === 'click')?.flags?.postGestureStabilization, + true, + ); assert.deepEqual(calls.find((call) => call.command === 'find')?.flags?.interactionOutcome, { retryOnNoChange: true, }); + assert.equal( + calls.find((call) => call.command === 'find')?.flags?.postGestureStabilization, + true, + ); }); test('runReplayScriptFile runs nested Maestro runtime commands inside runFlow.when', async () => { diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 9d13c9355..a91f24435 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -2135,6 +2135,7 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', expect.objectContaining({ platform: 'ios', id: 'sim-1' }), { command: 'uptime' }, expect.objectContaining({ + cleanStaleBundles: true, logPath: expect.stringMatching(/daemon\.log$/), requestId: 'prepare-request', }), diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 1c7d97146..5f29d7ca6 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -759,33 +759,37 @@ test('captureSnapshot lazily retries pending no-change touch before returning fr ], }; - mockDispatch - .mockResolvedValueOnce({ - nodes: baselineNodes, - backend: 'xctest', - }) - .mockResolvedValueOnce({ clicked: true }) - .mockResolvedValueOnce({ - nodes: [ - { - index: 0, - depth: 0, - type: 'Button', - label: 'Back', - identifier: 'back', - hittable: true, - rect: { x: 20, y: 60, width: 90, height: 44 }, - }, - { - index: 1, - depth: 0, - type: 'StaticText', - label: 'Feed', - rect: { x: 20, y: 140, width: 160, height: 48 }, - }, - ], + let pressed = false; + mockDispatch.mockImplementation(async (_device, command) => { + if (command === 'press') { + pressed = true; + return { clicked: true }; + } + return { + nodes: + !pressed + ? baselineNodes + : [ + { + index: 0, + depth: 0, + type: 'Button', + label: 'Back', + identifier: 'back', + hittable: true, + rect: { x: 20, y: 60, width: 90, height: 44 }, + }, + { + index: 1, + depth: 0, + type: 'StaticText', + label: 'Feed', + rect: { x: 20, y: 140, width: 160, height: 48 }, + }, + ], backend: 'xctest', - }); + }; + }); const result = await captureSnapshot({ device: iosSimulatorDevice, @@ -797,9 +801,154 @@ test('captureSnapshot lazily retries pending no-change touch before returning fr expect(result.snapshot.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'Feed' })]), ); - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'press', 'snapshot']); - expect(mockDispatch.mock.calls[1]?.[2]).toEqual(['100', '144']); + expect(mockDispatch.mock.calls.map((call) => call[1]).filter((command) => command === 'press')) + .toEqual(['press']); + expect(mockDispatch.mock.calls.find((call) => call[1] === 'press')?.[2]).toEqual([ + '100', + '144', + ]); + expect(session.pendingInteractionOutcome).toBeUndefined(); +}); + +test('captureSnapshot does not retry when a tap change appears after a short delay', async () => { + const sessionName = 'android-delayed-outcome-without-retry'; + const session = makeSession(sessionName, androidDevice); + const baselineNodes = [ + { + ref: 'e1', + index: 0, + depth: 0, + type: 'android.widget.Button', + label: 'Open drawer', + hittable: true, + rect: { x: 20, y: 120, width: 160, height: 48 }, + }, + ]; + const changedNodes = [ + { + index: 0, + depth: 0, + type: 'android.widget.TextView', + label: 'Albums', + rect: { x: 32, y: 240, width: 180, height: 52 }, + }, + ]; + session.pendingInteractionOutcome = { + action: 'click', + command: 'press', + positionals: ['100', '144'], + flags: { platform: 'android' }, + markedAt: Date.now(), + attemptsRemaining: 2, + preSignature: buildInteractionSurfaceSignature(baselineNodes), + }; + + let snapshotCalls = 0; + mockDispatch.mockImplementation(async (_device, command) => { + expect(command).toBe('snapshot'); + snapshotCalls += 1; + return { + nodes: snapshotCalls === 1 ? baselineNodes : changedNodes, + backend: 'android', + }; + }); + + const result = await captureSnapshot({ + device: androidDevice, + session, + flags: { snapshotInteractiveOnly: true }, + logPath: '/tmp/daemon.log', + }); + + expect(result.snapshot.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Albums' })]), + ); + expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); + expect(session.pendingInteractionOutcome).toBeUndefined(); +}); + +test('captureSnapshot retries pending tap outcome before post-gesture stabilization', async () => { + const sessionName = 'android-maestro-tap-outcome-before-stabilization'; + const session = makeSession(sessionName, androidDevice); + const baselineNodes = [ + { + ref: 'e1', + index: 0, + depth: 0, + type: 'android.widget.Button', + label: 'Navigate to Third', + hittable: true, + rect: { x: 302, y: 1301, width: 476, height: 110 }, + }, + ]; + session.snapshot = { + nodes: baselineNodes, + createdAt: Date.now(), + backend: 'android', + }; + session.pendingInteractionOutcome = { + action: 'click', + command: 'press', + positionals: ['540', '1356'], + flags: { platform: 'android' }, + markedAt: Date.now(), + attemptsRemaining: 2, + preSignature: [ + { + key: '|Navigate to Third||android.widget.Button||enabled|unselected|hittable|#0', + x: 302, + y: 1301, + width: 476, + height: 110, + }, + ], + }; + session.postGestureStabilization = { + action: 'click', + markedAt: Date.now(), + }; + + let pressed = false; + mockDispatch.mockImplementation(async (_device, command) => { + if (command === 'press') { + pressed = true; + return { clicked: true }; + } + return { + nodes: + !pressed + ? baselineNodes + : [ + { + index: 0, + depth: 0, + type: 'android.widget.TextView', + label: 'Tab Third (3)', + rect: { x: 390, y: 884, width: 300, height: 55 }, + }, + ], + backend: 'android', + }; + }); + + const result = await captureSnapshot({ + device: androidDevice, + session, + flags: { snapshotInteractiveOnly: true }, + logPath: '/tmp/daemon.log', + }); + + expect(result.snapshot.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tab Third (3)' })]), + ); + expect(mockDispatch.mock.calls.map((call) => call[1]).filter((command) => command === 'press')) + .toEqual(['press']); + expect(mockDispatch.mock.calls.find((call) => call[1] === 'press')?.[2]).toEqual([ + '540', + '1356', + ]); expect(session.pendingInteractionOutcome).toBeUndefined(); + expect(session.postGestureStabilization).toBeUndefined(); }); test('captureSnapshot composes pending outcome retry with Android freshness capture', async () => { diff --git a/src/daemon/handlers/interaction-common.ts b/src/daemon/handlers/interaction-common.ts index ec34c1dfc..2667350e6 100644 --- a/src/daemon/handlers/interaction-common.ts +++ b/src/daemon/handlers/interaction-common.ts @@ -13,6 +13,7 @@ import { markPendingInteractionOutcome, stripInternalInteractionOutcomeFlags, } from '../interaction-outcome-policy.ts'; +import { markPostGestureStabilization } from '../post-gesture-stabilization.ts'; export type ContextFromFlags = ( flags: CommandFlags | undefined, @@ -115,6 +116,7 @@ export function finalizeTouchInteraction(params: { if (isNavigationSensitiveAction(command)) { markAndroidSnapshotFreshness(session, command, androidFreshnessBaseline ?? session.snapshot); } + markPostGestureStabilization(session, command, retryPositionals ?? positionals, flags); recordTouchVisualizationEvent( session, command, diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index f58632ae7..ed3bbf5bf 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -110,6 +110,7 @@ function buildPrepareIosRunnerOptions( verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath, + cleanStaleBundles: true, requestId: req.meta?.requestId, }; } diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index bcde1f14c..ccb399706 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -29,13 +29,19 @@ import { } from '../android-snapshot-freshness.ts'; import { contextFromFlags } from '../context.ts'; import { + buildInteractionSurfaceSignature, + classifyInteractionSurfaceChange, clearPendingInteractionOutcome, emitInteractionSettled, emitInteractionSettleTimeout, getActivePendingInteractionOutcome, retryPendingInteractionOutcome, + type InteractionSurfaceChange, } from '../interaction-outcome-policy.ts'; -import { capturePostGestureStabilizedSnapshot } from '../post-gesture-stabilization.ts'; +import { + capturePostGestureStabilizedSnapshot, + capturePostGestureStabilizedResult, +} from '../post-gesture-stabilization.ts'; import { findNodeByLabel, pruneGroupNodes, resolveRefLabel } from '../snapshot-processing.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; import { presentIosInteractiveSnapshot } from '../snapshot-presentation/ios/index.ts'; @@ -66,6 +72,7 @@ type SnapshotAttempt = { type AndroidFreshnessReason = 'empty-interactive' | 'sharp-drop' | 'stuck-route'; type AndroidFreshnessMode = 'default' | 'ref-refresh'; +const INTERACTION_CHANGE_RECHECK_DELAY_MS = 500; export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{ snapshot: SnapshotState; @@ -73,6 +80,13 @@ export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{ androidSnapshot?: AndroidSnapshotBackendMetadata; freshness?: AndroidFreshnessCaptureMeta; }> { + const pendingInteractionOutcome = getActivePendingInteractionOutcome(params.session); + if (pendingInteractionOutcome && params.session) { + return await captureInteractionOutcomeAwareSnapshot( + { ...params, session: params.session }, + pendingInteractionOutcome, + ); + } if ( (params.device.platform === 'ios' || params.device.platform === 'android') && params.session?.postGestureStabilization @@ -84,13 +98,6 @@ export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{ }), }; } - const pendingInteractionOutcome = getActivePendingInteractionOutcome(params.session); - if (pendingInteractionOutcome && params.session) { - return await captureInteractionOutcomeAwareSnapshot( - { ...params, session: params.session }, - pendingInteractionOutcome, - ); - } const freshness = getActiveAndroidSnapshotFreshness(params.session); if (freshness && params.device.platform === 'android') { return await captureAndroidFreshnessAwareSnapshot(params, freshness); @@ -117,26 +124,42 @@ async function captureInteractionOutcomeAwareSnapshot( const startedAt = Date.now(); let retryAttempts = 0; - let latest = await captureSnapshotAttemptForInteractionOutcome(params); + let settled = await waitForDelayedInteractionSurfaceChange( + params, + pending, + await captureSnapshotAttemptForInteractionOutcome(params), + ); + let latest = settled.latest; let outcome = await retryPendingInteractionOutcome({ session, pending, logPath: params.logPath, - snapshot: latest.snapshot, + snapshot: settled.latest.snapshot, }); while (outcome.retried) { retryAttempts += 1; - latest = await captureSnapshotAttemptForInteractionOutcome(params); + settled = await waitForDelayedInteractionSurfaceChange( + params, + pending, + await captureSnapshotAttemptForInteractionOutcome(params), + ); + latest = settled.latest; outcome = await retryPendingInteractionOutcome({ session, pending, logPath: params.logPath, - snapshot: latest.snapshot, + snapshot: settled.latest.snapshot, }); } clearPendingInteractionOutcome(session); + latest = await capturePostGestureStabilizedResult({ + session, + initial: latest, + capture: async () => await captureSnapshotAttemptForInteractionOutcome(params), + readSnapshot: (attempt) => attempt.snapshot, + }); if (outcome.change !== 'ambiguous' && latest.freshness?.staleAfterRetries !== true) { clearAndroidSnapshotFreshness(session); } @@ -159,6 +182,28 @@ async function captureInteractionOutcomeAwareSnapshot( }; } +async function waitForDelayedInteractionSurfaceChange( + params: CaptureSnapshotParams & { session: SessionState }, + pending: NonNullable, + initial: SnapshotAttempt, +): Promise<{ latest: SnapshotAttempt; change: InteractionSurfaceChange }> { + let latest = initial; + let change = classifyInteractionSurfaceChange( + pending.preSignature, + buildInteractionSurfaceSignature(latest.snapshot.nodes), + ); + if (change !== 'unchanged') return { latest, change }; + + await sleep(INTERACTION_CHANGE_RECHECK_DELAY_MS); + latest = await captureSnapshotAttemptForInteractionOutcome(params); + change = classifyInteractionSurfaceChange( + pending.preSignature, + buildInteractionSurfaceSignature(latest.snapshot.nodes), + ); + + return { latest, change }; +} + export async function captureSnapshotData(params: CaptureSnapshotParams): Promise { const { device, session, flags, outPath, logPath, snapshotScope } = params; if (device.platform === 'linux') { diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-outcome-policy.ts index 1e7f8c6eb..78e970e81 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-outcome-policy.ts @@ -137,8 +137,12 @@ export function emitInteractionSettleTimeout(params: { export function stripInternalInteractionOutcomeFlags( flags: CommandFlags | undefined, ): CommandFlags | undefined { - if (!flags?.interactionOutcome) return flags; - const { interactionOutcome: _interactionOutcome, ...publicFlags } = flags; + if (!flags?.interactionOutcome && !flags?.postGestureStabilization) return flags; + const { + interactionOutcome: _interactionOutcome, + postGestureStabilization: _postGestureStabilization, + ...publicFlags + } = flags; return publicFlags; } diff --git a/src/daemon/post-gesture-stabilization.ts b/src/daemon/post-gesture-stabilization.ts index c952eec26..7e8207a0f 100644 --- a/src/daemon/post-gesture-stabilization.ts +++ b/src/daemon/post-gesture-stabilization.ts @@ -1,4 +1,5 @@ import { emitDiagnostic } from '../utils/diagnostics.ts'; +import type { CommandFlags } from '../core/dispatch.ts'; import type { SnapshotState } from '../utils/snapshot.ts'; import { sleep } from '../utils/timeouts.ts'; import { @@ -10,9 +11,14 @@ import type { SessionState } from './types.ts'; const STABILIZATION_DEADLINE_MS = 1_500; const STABILIZATION_INTERVAL_MS = 200; -export function markPostGestureStabilization(session: SessionState, action: string): void { +export function markPostGestureStabilization( + session: SessionState, + action: string, + positionals: string[] = [], + flags?: CommandFlags, +): void { if (!supportsPostGestureStabilization(session.device.platform)) return; - if (!isPostGestureStabilizingAction(action)) return; + if (!isPostGestureStabilizingAction(action, positionals, flags)) return; session.postGestureStabilization = { action, markedAt: Date.now(), @@ -28,22 +34,35 @@ export async function capturePostGestureStabilizedSnapshot(params: { session: SessionState | undefined; capture: () => Promise; }): Promise { + return await capturePostGestureStabilizedResult({ + session: params.session, + capture: params.capture, + readSnapshot: (snapshot) => snapshot, + }); +} + +export async function capturePostGestureStabilizedResult(params: { + session: SessionState | undefined; + capture: () => Promise; + readSnapshot: (result: T) => SnapshotState; + initial?: T; +}): Promise { const { session, capture } = params; const pending = session?.postGestureStabilization; if (!session || !supportsPostGestureStabilization(session.device.platform) || !pending) { - return await capture(); + return params.initial ?? (await capture()); } const startedAt = Date.now(); let attempts = 1; - let previous = await capture(); - let previousSignature = buildInteractionSurfaceSignature(previous.nodes); + let previous = params.initial ?? (await capture()); + let previousSignature = buildInteractionSurfaceSignature(params.readSnapshot(previous).nodes); while (Date.now() - startedAt < STABILIZATION_DEADLINE_MS) { await sleep(STABILIZATION_INTERVAL_MS); attempts += 1; const current = await capture(); - const currentSignature = buildInteractionSurfaceSignature(current.nodes); + const currentSignature = buildInteractionSurfaceSignature(params.readSnapshot(current).nodes); if (areInteractionSurfaceSignaturesStable(previousSignature, currentSignature)) { clearPostGestureStabilization(session); emitDiagnostic({ @@ -74,8 +93,14 @@ export async function capturePostGestureStabilizedSnapshot(params: { return previous; } -function isPostGestureStabilizingAction(action: string): boolean { - return action === 'swipe' || action === 'scroll'; +function isPostGestureStabilizingAction( + action: string, + positionals: string[], + flags: CommandFlags | undefined, +): boolean { + if (flags?.postGestureStabilization === true) return true; + if (action === 'swipe' || action === 'scroll') return true; + return action === 'gesture' && positionals[0] === 'swipe'; } function supportsPostGestureStabilization(platform: SessionState['device']['platform']): boolean { diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index aed090d94..ed7e08ab2 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -120,7 +120,7 @@ export async function dispatchGenericCommand(params: { if (isNavigationSensitiveAction(platformCommand)) { markAndroidSnapshotFreshness(session, platformCommand); } - markPostGestureStabilization(session, platformCommand); + markPostGestureStabilization(session, platformCommand, resolvedPositionals, req.flags); return { ok: true, data: data ?? {} }; } diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index c5de0685e..d2845fa19 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -488,6 +488,23 @@ test('runner session keeps boot and stale bundle cleanup available when needed', mockRunXcrun.mock.calls.some((call) => call[0]?.includes('uninstall')), true, ); + const uninstallCalls = mockRunXcrun.mock.calls.filter((call) => call[0]?.includes('uninstall')); + assert.equal(uninstallCalls.every((call) => call[1]?.timeoutMs === 10_000), true); +}); + +test('runner session stale bundle cleanup is best-effort when simctl stalls', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-clean-timeout-sim' }; + + mockRunXcrun + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'simctl uninstall timed out')) + .mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + + const session = await ensureRunnerSession(device, { + cleanStaleBundles: true, + }); + + assert.equal(session.deviceId, device.id); + assert.equal(mockRunCmdBackground.mock.calls.length, 1); }); test('runner session stop sends shutdown, cleans temporary runner files, and releases simulator scope', async () => { diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index 99b669a8e..de327a010 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -6,6 +6,7 @@ export type AppleRunnerCommandOptions = { verbose?: boolean; logPath?: string; traceLogPath?: string; + cleanStaleBundles?: boolean; requestId?: string; }; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 84099e0d3..3a9d1b54d 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -49,6 +49,7 @@ const RUNNER_INVALIDATE_WAIT_TIMEOUT_MS = 1_000; const RUNNER_READY_PREFLIGHT_TIMEOUT_MS = 5_000; const RUNNER_TAP_PREFLIGHT_SKIP_FRESHNESS_MS = 10_000; const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000; +const RUNNER_STALE_BUNDLE_UNINSTALL_TIMEOUT_MS = 10_000; type RunnerReadinessPreflightDecision = | { @@ -204,28 +205,50 @@ async function cleanupStaleSimulatorRunnerBundles(device: DeviceInfo): Promise { + try { + return await runXcrun(buildSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]), { + allowFailure: true, + timeoutMs: RUNNER_STALE_BUNDLE_UNINSTALL_TIMEOUT_MS, + }); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'ios_runner_startup_cleanup_stale_bundle_failed', + data: { + deviceId: device.id, + bundleId, + timeoutMs: RUNNER_STALE_BUNDLE_UNINSTALL_TIMEOUT_MS, + error: error instanceof Error ? error.message : String(error), + }, + }); + return undefined; + } +} + +function isBenignSimulatorRunnerUninstallResult(result: ExecResult): boolean { + if (result.exitCode === 0) return true; + const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); + return ( + output.includes('not installed') || + output.includes('found nothing') || + output.includes('no such file') || + output.includes('invalid device') || + output.includes('could not find') + ); +} + export function getRunnerSessionSnapshot( deviceId: string, ): { sessionId: string; alive: boolean } | null { diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index 277d5fc26..fa2416db2 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -340,7 +340,7 @@ test('Provider-backed integration Android Maestro executes runFlow conditions an ['shell', 'input', 'tap', '180', '330'], ], ); - assert.equal(snapshots, 5); + assert.equal(snapshots, 9); }, ); }); From 21a149edc50b9e4488a00d1997a9f87cdff8a0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 11:07:27 -0700 Subject: [PATCH 02/12] fix: harden maestro ci diagnostics and ios prepare --- .../__tests__/runtime-assertions.test.ts | 43 ++++++++++++ src/compat/maestro/runtime-assertions.ts | 67 ++++++++++++++++++- src/daemon/handlers/__tests__/session.test.ts | 1 + src/daemon/handlers/session.ts | 9 +++ .../__tests__/runner-command-retry.test.ts | 20 ++++++ src/platforms/ios/runner-client.ts | 12 +++- src/platforms/ios/runner-provider.ts | 1 + src/platforms/ios/runner-session-types.ts | 1 + src/platforms/ios/runner-session.ts | 14 +++- 9 files changed, 161 insertions(+), 7 deletions(-) diff --git a/src/compat/maestro/__tests__/runtime-assertions.test.ts b/src/compat/maestro/__tests__/runtime-assertions.test.ts index 858695be5..6928b225f 100644 --- a/src/compat/maestro/__tests__/runtime-assertions.test.ts +++ b/src/compat/maestro/__tests__/runtime-assertions.test.ts @@ -1,4 +1,7 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { invokeMaestroAssertNotVisible, @@ -230,6 +233,46 @@ test('invokeMaestroAssertVisible does not use Android raw fallback for generated assert.equal(snapshotFlags.some((flags) => flags?.snapshotRaw === true), false); }); +test('invokeMaestroAssertVisible writes terminal snapshot artifacts for failed attempts', async () => { + const artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'maestro-assert-artifacts-')); + try { + const response = await invokeMaestroAssertVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'android', artifactsDir }, + }, + positionals: ['id="album-0"', '0'], + invoke: async (): Promise => ({ + ok: true, + data: snapshot([ + node('Chat', { identifier: 'chat-tab', type: 'android.widget.Button' }), + node('Contacts', { identifier: 'contacts-tab', type: 'android.widget.Button' }), + ]), + }), + }); + + assert.equal(response.ok, false); + if (!response.ok) { + const artifactPaths = response.error.details?.artifactPaths; + assert.deepEqual(artifactPaths, [ + path.join(artifactsDir, 'failure-snapshot.json'), + path.join(artifactsDir, 'failure-snapshot.txt'), + ]); + } + assert.match( + fs.readFileSync(path.join(artifactsDir, 'failure-snapshot.txt'), 'utf8'), + /@e1 \[button\] "Chat"/, + ); + assert.match( + fs.readFileSync(path.join(artifactsDir, 'failure-snapshot.json'), 'utf8'), + /"identifier": "chat-tab"/, + ); + } finally { + fs.rmSync(artifactsDir, { recursive: true, force: true }); + } +}); + test('invokeMaestroAssertVisible treats an elapsed ellipsis loading gate as already past loading', async () => { vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(250); diff --git a/src/compat/maestro/runtime-assertions.ts b/src/compat/maestro/runtime-assertions.ts index 0ff668d6b..669ab88eb 100644 --- a/src/compat/maestro/runtime-assertions.ts +++ b/src/compat/maestro/runtime-assertions.ts @@ -1,7 +1,10 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts'; import type { DaemonResponse } from '../../daemon/types.ts'; import type { ReplayVarScope } from '../../replay/vars.ts'; import type { SnapshotState } from '../../utils/snapshot.ts'; +import { buildSnapshotDisplayLines } from '../../utils/snapshot-lines.ts'; import { sleep } from '../../utils/timeouts.ts'; import { captureMaestroSnapshot, @@ -116,6 +119,7 @@ async function invokeSnapshotMaestroAssertVisible( const startedAt = Date.now(); const deadlineMs = args.timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs; let lastResponse: DaemonResponse | undefined; + let lastSnapshot: SnapshotState | undefined; let capturedAfterDeadline = false; while (true) { const captureStartedAt = Date.now(); @@ -124,6 +128,7 @@ async function invokeSnapshotMaestroAssertVisible( }); if (sample.visible) return visibleAssertionResponse(sample.response, args.selector, startedAt); lastResponse = sample.response; + lastSnapshot = sample.snapshot ?? lastSnapshot; const failedSample = handleFailedVisibleSample(params.baseReq, args, sample, startedAt); if (failedSample.kind === 'return') return failedSample.response; @@ -141,13 +146,13 @@ async function invokeSnapshotMaestroAssertVisible( await sleep(MAESTRO_ASSERTION_POLICY.assertVisiblePollMs); } - return ( + const response = lastResponse ?? errorResponse('COMMAND_FAILED', `Expected visible but did not match: ${args.selector}`, { selector: args.selector, timeoutMs: args.timeoutMs, - }) - ); + }); + return withMaestroFailureSnapshotArtifacts(response, lastSnapshot, params.baseReq); } function handleFailedVisibleSample( @@ -378,6 +383,62 @@ function visibleAssertionResponse( }; } +function withMaestroFailureSnapshotArtifacts( + response: DaemonResponse, + snapshot: SnapshotState | undefined, + baseReq: ReplayBaseRequest, +): DaemonResponse { + if (response.ok || !snapshot) return response; + const artifactsDir = + typeof baseReq.flags?.artifactsDir === 'string' ? baseReq.flags.artifactsDir : undefined; + if (!artifactsDir) return response; + + const artifactPaths = writeMaestroFailureSnapshotArtifacts(snapshot, artifactsDir); + if (artifactPaths.length === 0) return response; + return { + ok: false, + error: { + ...response.error, + details: { + ...(response.error.details ?? {}), + artifactPaths: uniqueStrings([ + ...readExistingArtifactPaths(response.error.details?.artifactPaths), + ...artifactPaths, + ]), + }, + }, + }; +} + +function writeMaestroFailureSnapshotArtifacts( + snapshot: SnapshotState, + artifactsDir: string, +): string[] { + try { + fs.mkdirSync(artifactsDir, { recursive: true }); + const jsonPath = path.join(artifactsDir, 'failure-snapshot.json'); + const textPath = path.join(artifactsDir, 'failure-snapshot.txt'); + fs.writeFileSync(jsonPath, `${JSON.stringify(snapshot, null, 2)}\n`); + const lines = buildSnapshotDisplayLines(snapshot.nodes, { + summarizeTextSurfaces: true, + }).map((line) => line.text); + fs.writeFileSync(textPath, `${lines.join('\n')}\n`); + return [jsonPath, textPath]; + } catch { + return []; + } +} + +function readExistingArtifactPaths(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === 'string') + : []; +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + function shouldCaptureOnceAfterDeadline( capturedAfterDeadline: boolean, captureStartedAt: number, diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index a91f24435..a4ed5a97f 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -2138,6 +2138,7 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', cleanStaleBundles: true, logPath: expect.stringMatching(/daemon\.log$/), requestId: 'prepare-request', + startupTimeoutMs: 240000, }), ); expect((response as any).data).toMatchObject({ diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index ed3bbf5bf..601f34ca8 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -41,6 +41,7 @@ const INVENTORY_COMMANDS = DAEMON_COMMAND_GROUPS.inventory; const STATE_COMMANDS = DAEMON_COMMAND_GROUPS.state; const OBSERVABILITY_COMMANDS = DAEMON_COMMAND_GROUPS.observability; const REPLAY_COMMANDS = DAEMON_COMMAND_GROUPS.replay; +const PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS = 45_000; export const SESSION_COMMAND_HANDLERS = { ...Object.fromEntries([...INVENTORY_COMMANDS].map((command) => [command, true] as const)), @@ -111,10 +112,18 @@ function buildPrepareIosRunnerOptions( logPath, traceLogPath: session?.trace?.outPath, cleanStaleBundles: true, + startupTimeoutMs: resolvePrepareIosRunnerStartupTimeoutMs(req.flags?.timeoutMs), requestId: req.meta?.requestId, }; } +function resolvePrepareIosRunnerStartupTimeoutMs(timeoutMs: unknown): number | undefined { + if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return undefined; + } + return Math.max(PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS, Math.floor(timeoutMs)); +} + function prepareIosRunnerResponseData( action: string, device: DeviceInfo, diff --git a/src/platforms/ios/__tests__/runner-command-retry.test.ts b/src/platforms/ios/__tests__/runner-command-retry.test.ts index a377dc6f7..6d0c08874 100644 --- a/src/platforms/ios/__tests__/runner-command-retry.test.ts +++ b/src/platforms/ios/__tests__/runner-command-retry.test.ts @@ -350,6 +350,26 @@ test('read-only commands retry when completed status has no retained response', }); }); +test('read-only startup commands use the session startup timeout override', async () => { + const session = makeRunnerSession({ + port: 8100, + ready: false, + startupTimeoutMs: 240_000, + }); + + mockEnsureRunnerSession.mockResolvedValue(session); + mockExecuteRunnerCommandWithSession.mockResolvedValue({ currentUptimeMs: 42 }); + + const result = await runIosRunnerCommand( + IOS_SIMULATOR, + { command: 'uptime' }, + { startupTimeoutMs: 240_000 }, + ); + + assert.deepEqual(result, { currentUptimeMs: 42 }); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 240_000); +}); + test('read-only commands retry when status shows in-flight work', async () => { const session = makeRunnerSession({ port: 8100, ready: true }); diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 6646dd79d..7fcafd4a4 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -135,7 +135,9 @@ async function executeRunnerCommand( let session: RunnerSession | undefined; try { session = await ensureRunnerSession(device, options); - const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS; + const timeoutMs = session.ready + ? RUNNER_COMMAND_TIMEOUT_MS + : readRunnerStartupTimeoutMs(session); return await executeRunnerCommandWithSession( device, session, @@ -162,7 +164,7 @@ async function executeRunnerCommand( session, command, options.logPath, - RUNNER_STARTUP_TIMEOUT_MS, + readRunnerStartupTimeoutMs(session), signal, ); } catch (retryErr) { @@ -197,7 +199,7 @@ async function executeRunnerCommand( session, command, options.logPath, - RUNNER_STARTUP_TIMEOUT_MS, + readRunnerStartupTimeoutMs(session), signal, ); emitDiagnostic({ @@ -248,6 +250,10 @@ async function executeRunnerCommand( } } +function readRunnerStartupTimeoutMs(session: Pick): number { + return session.startupTimeoutMs ?? RUNNER_STARTUP_TIMEOUT_MS; +} + async function handleRunnerTransportErrorAfterCommandSend( device: DeviceInfo, session: RunnerSession, diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index de327a010..8c23b250b 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -7,6 +7,7 @@ export type AppleRunnerCommandOptions = { logPath?: string; traceLogPath?: string; cleanStaleBundles?: boolean; + startupTimeoutMs?: number; requestId?: string; }; diff --git a/src/platforms/ios/runner-session-types.ts b/src/platforms/ios/runner-session-types.ts index 5bd6cf1fd..073e3c070 100644 --- a/src/platforms/ios/runner-session-types.ts +++ b/src/platforms/ios/runner-session-types.ts @@ -11,6 +11,7 @@ export type RunnerSession = { testPromise: Promise; child: ExecBackgroundResult['child']; ready: boolean; + startupTimeoutMs?: number; lastSuccessfulRunnerResponseAtMs?: number; startupTimings?: Record; startupTimingsReported?: boolean; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 3a9d1b54d..d24ad6a35 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -39,6 +39,7 @@ export type RunnerSessionOptions = { logPath?: string; traceLogPath?: string; cleanStaleBundles?: boolean; + startupTimeoutMs?: number; requestId?: string; }; @@ -191,6 +192,7 @@ export async function ensureRunnerSession( testPromise, child, ready: false, + startupTimeoutMs: normalizeRunnerStartupTimeoutMs(options.startupTimeoutMs), startupTimings, simulatorSetRedirect: simulatorSetRedirect ?? undefined, }; @@ -504,7 +506,7 @@ export async function executeRunnerCommandWithSession( if (preflightDecision.action === 'run') { const readinessTimeoutMs = session.ready ? Math.min(RUNNER_READY_PREFLIGHT_TIMEOUT_MS, deadline.remainingMs()) - : Math.min(RUNNER_STARTUP_TIMEOUT_MS, deadline.remainingMs()); + : Math.min(readRunnerStartupTimeoutMs(session), deadline.remainingMs()); try { const readinessResponse = await withDiagnosticTimer( 'ios_runner_readiness_preflight', @@ -701,6 +703,16 @@ function markRunnerPreflightError(error: unknown, details: Record): number { + return session.startupTimeoutMs ?? RUNNER_STARTUP_TIMEOUT_MS; +} + +function normalizeRunnerStartupTimeoutMs(value: number | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + async function measureRunnerStartupStep( timings: Record, phase: string, From ea323f5ac9cb46bea20f2bbb3b233d85ea5f6169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 11:34:53 -0700 Subject: [PATCH 03/12] fix: preserve android freshness baseline for optimized taps --- src/daemon/android-snapshot-freshness.ts | 20 +++++- .../handlers/__tests__/interaction.test.ts | 66 +++++++++++++++++++ .../handlers/__tests__/session-replay.test.ts | 23 +++++++ src/daemon/handlers/session-replay-runtime.ts | 17 ++++- src/daemon/handlers/snapshot-session.ts | 8 ++- src/daemon/session-snapshot.ts | 3 + src/daemon/types.ts | 2 + 7 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/daemon/android-snapshot-freshness.ts b/src/daemon/android-snapshot-freshness.ts index c428d735e..3d9ab8d6d 100644 --- a/src/daemon/android-snapshot-freshness.ts +++ b/src/daemon/android-snapshot-freshness.ts @@ -8,6 +8,7 @@ export type { AndroidSnapshotFreshness } from './types.ts'; // and can lag behind real transitions by up to ~2 s; 2.5 s gives a comfortable margin // while avoiding unnecessary retries for steady-state interactions like typing. const ANDROID_FRESHNESS_WINDOW_MS = 2_500; +const ANDROID_COMPARISON_BASELINE_MAX_AGE_MS = 5_000; // Retry suspicious snapshots until this post-action deadline expires. The delay // sequence stays short in the happy path; the 600 ms tail retry is opportunistic @@ -28,21 +29,34 @@ export function markAndroidSnapshotFreshness( baseline = session.snapshot, ): void { if (session.device.platform !== 'android') return; + const comparisonBaseline = resolveAndroidComparisonBaseline(session, baseline); // Route-stuck recovery only makes sense against a baseline captured in a broad, comparable // shape. Interactive/scoped/depth-limited snapshots are still useful for users, but they are // too pruned to serve as a reliable "same route vs new route" baseline. - const routeComparable = baseline?.comparisonSafe === true; + const routeComparable = comparisonBaseline?.comparisonSafe === true; session.androidSnapshotFreshness = { action, markedAt: Date.now(), - baselineCount: baseline?.nodes.length ?? 0, + baselineCount: (comparisonBaseline ?? baseline)?.nodes.length ?? 0, baselineSignatures: routeComparable - ? buildSnapshotSignatures(baseline?.nodes ?? []) + ? buildSnapshotSignatures(comparisonBaseline?.nodes ?? []) : undefined, routeComparable, }; } +function resolveAndroidComparisonBaseline( + session: SessionState, + baseline: SnapshotState | undefined, +): SnapshotState | undefined { + if (baseline?.comparisonSafe === true) return baseline; + const previous = session.lastComparisonSafeSnapshot; + if (!previous || previous.comparisonSafe !== true) return baseline; + return Date.now() - previous.createdAt <= ANDROID_COMPARISON_BASELINE_MAX_AGE_MS + ? previous + : baseline; +} + export function getActiveAndroidSnapshotFreshness( session: SessionState | undefined, ): AndroidSnapshotFreshness | undefined { diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index a948957e7..9e4184b1a 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -1446,6 +1446,72 @@ test('press @ref falls back to cached Android ref when freshness refresh fails', }); }); +test('coordinate press preserves Android route freshness from last comparable snapshot', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-coordinate-freshness-baseline'; + const session = makeAndroidSession(sessionName); + const comparableSnapshot = { + nodes: attachRefs([ + { + index: 0, + type: 'android.widget.ScrollView', + label: 'Albums', + rect: { x: 0, y: 0, width: 400, height: 700 }, + }, + { + index: 1, + type: 'android.widget.Button', + label: 'Go to Contacts', + rect: { x: 16, y: 120, width: 160, height: 48 }, + enabled: true, + hittable: true, + }, + ]), + createdAt: Date.now(), + backend: 'android' as const, + comparisonSafe: true, + }; + session.lastComparisonSafeSnapshot = comparableSnapshot; + session.snapshot = { + nodes: attachRefs([ + { + index: 0, + type: 'android.widget.Button', + label: 'Go to Contacts', + rect: { x: 16, y: 120, width: 160, height: 48 }, + enabled: true, + hittable: true, + }, + ]), + createdAt: Date.now(), + backend: 'android', + comparisonSafe: false, + }; + sessionStore.set(sessionName, session); + mockDispatch.mockResolvedValue({ pressed: true }); + + const response = await handleInteractionCommands({ + req: { + token: 't', + session: sessionName, + command: 'press', + positionals: ['96', '144'], + flags: {}, + }, + sessionName, + sessionStore, + contextFromFlags, + }); + + expect(response?.ok).toBe(true); + expect(sessionStore.get(sessionName)?.androidSnapshotFreshness).toMatchObject({ + action: 'press', + baselineCount: 2, + baselineSignatures: expect.any(Array), + routeComparable: true, + }); +}); + test('press @ref fails when Android tap escapes to launcher', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-escape'; diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts index 6e9a43955..166e9e068 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { test } from 'vitest'; import { buildNestedReplayFlags } from '../session-replay.ts'; +import { collectReplayActionArtifactPaths } from '../session-replay-runtime.ts'; test('buildNestedReplayFlags returns parent flags untouched when neither override is set', () => { const parent = { platform: 'android' as const, timeoutMs: 5000 }; @@ -51,3 +55,22 @@ test('buildNestedReplayFlags overrides a parent artifactsDir with the attempt-le }); assert.equal(result?.artifactsDir, '/suite-root/flow/attempt-2'); }); + +test('collectReplayActionArtifactPaths includes failed action artifact details', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-artifacts-')); + const snapshotPath = path.join(root, 'failure-snapshot.txt'); + fs.writeFileSync(snapshotPath, 'snapshot'); + + const paths = collectReplayActionArtifactPaths({ + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'assertion failed', + details: { + artifactPaths: [snapshotPath, path.join(root, 'missing.txt')], + }, + }, + }); + + assert.deepEqual(paths, [snapshotPath]); +}); diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 096716bf1..4e95da291 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -102,6 +102,7 @@ export async function runReplayScriptFile(params: { collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); continue; } + collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); if (!shouldUpdate) { return withReplayFailureContext(response, action, index, resolved, [...artifactPaths]); } @@ -129,6 +130,7 @@ export async function runReplayScriptFile(params: { invoke, }); if (!response.ok) { + collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); return withReplayFailureContext(response, nextAction, index, resolved, [...artifactPaths]); } collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); @@ -231,7 +233,20 @@ function withReplayFailureContext( // fallow-ignore-next-line complexity export function collectReplayActionArtifactPaths(response: DaemonResponse): string[] { - if (!response.ok || !response.data) return []; + if (!response.ok) { + const paths = response.error.details?.artifactPaths; + return Array.isArray(paths) + ? [ + ...new Set( + paths.filter( + (candidate): candidate is string => + typeof candidate === 'string' && isReplayArtifactPath(candidate), + ), + ), + ] + : []; + } + if (!response.data) return []; const candidates: string[] = []; if (typeof response.data.path === 'string') candidates.push(response.data.path); if (typeof response.data.outPath === 'string') candidates.push(response.data.outPath); diff --git a/src/daemon/handlers/snapshot-session.ts b/src/daemon/handlers/snapshot-session.ts index 477040775..b34f8ab94 100644 --- a/src/daemon/handlers/snapshot-session.ts +++ b/src/daemon/handlers/snapshot-session.ts @@ -56,7 +56,12 @@ export function buildSnapshotSession(params: { }): SessionState { const { session, sessionName, device, snapshot, appBundleId } = params; if (session) { - return { ...session, snapshot }; + return { + ...session, + snapshot, + lastComparisonSafeSnapshot: + snapshot?.comparisonSafe === true ? snapshot : session.lastComparisonSafeSnapshot, + }; } return { name: sessionName, @@ -64,6 +69,7 @@ export function buildSnapshotSession(params: { createdAt: Date.now(), appBundleId, snapshot, + ...(snapshot?.comparisonSafe === true ? { lastComparisonSafeSnapshot: snapshot } : {}), actions: [], }; } diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 011c55944..642937945 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -4,4 +4,7 @@ import type { SessionState } from './types.ts'; export function setSessionSnapshot(session: SessionState, snapshot: SnapshotState): void { session.snapshot = snapshot; session.snapshotScopeSource = undefined; + if (snapshot.comparisonSafe === true) { + session.lastComparisonSafeSnapshot = snapshot; + } } diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 2e65f0cf0..302d0d620 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -211,6 +211,8 @@ export type SessionState = { snapshot?: SnapshotState; /** Source snapshot used to resolve repeated `snapshot -s @ref` after scoped output replaces refs. */ snapshotScopeSource?: SnapshotState; + /** Last broad snapshot safe for Android route-freshness comparisons after interactive snapshots. */ + lastComparisonSafeSnapshot?: SnapshotState; androidSnapshotFreshness?: AndroidSnapshotFreshness; postGestureStabilization?: PostGestureStabilization; pendingInteractionOutcome?: PendingInteractionOutcome; From e3deb892dbc83844d6baa6ef491ea9bacdbdaa41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 11:52:45 -0700 Subject: [PATCH 04/12] fix: close ios runner host after sessionless commands --- .../__tests__/snapshot-handler.test.ts | 49 ++++++++++++++++++- src/daemon/handlers/snapshot-session.ts | 23 ++++++++- src/platforms/ios/runner-client.ts | 1 + src/platforms/ios/runner-xctestrun.ts | 2 +- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 5f29d7ca6..1a4e976ef 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { PNG } from '../../../utils/png.ts'; import { handleSnapshotCommands } from '../snapshot.ts'; +import { withSessionlessRunnerCleanup } from '../snapshot-session.ts'; import { captureSnapshot } from '../snapshot-capture.ts'; import { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; @@ -29,11 +30,25 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => { }; }); +vi.mock('../../../platforms/ios/apps.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + closeIosApp: vi.fn(async () => {}), + }; +}); + import { dispatchCommand } from '../../../core/dispatch.ts'; -import { runIosRunnerCommand } from '../../../platforms/ios/runner-client.ts'; +import { + runIosRunnerCommand, + stopIosRunnerSession, +} from '../../../platforms/ios/runner-client.ts'; +import { closeIosApp } from '../../../platforms/ios/apps.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockRunnerCommand = vi.mocked(runIosRunnerCommand); +const mockStopIosRunnerSession = vi.mocked(stopIosRunnerSession); +const mockCloseIosApp = vi.mocked(closeIosApp); function makeSessionStore(): SessionStore { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-snapshot-handler-')); @@ -80,6 +95,10 @@ beforeEach(() => { mockDispatch.mockResolvedValue({}); mockRunnerCommand.mockReset(); mockRunnerCommand.mockResolvedValue({}); + mockStopIosRunnerSession.mockReset(); + mockStopIosRunnerSession.mockResolvedValue(); + mockCloseIosApp.mockReset(); + mockCloseIosApp.mockResolvedValue(); }); function writeSolidPng(filePath: string, width = 390, height = 844): void { @@ -1848,3 +1867,31 @@ test('wait sleep bypasses sessionless runner cleanup wrapper', async () => { expect(response).toBeTruthy(); expect(response?.ok).toBe(true); }); + +test('sessionless iOS runner cleanup stops the runner host app', async () => { + const result = await withSessionlessRunnerCleanup(undefined, iosSimulatorDevice, async () => { + return 'ok'; + }); + + expect(result).toBe('ok'); + expect(mockStopIosRunnerSession).toHaveBeenCalledWith(iosSimulatorDevice.id); + expect(mockCloseIosApp).toHaveBeenCalledWith( + iosSimulatorDevice, + 'com.callstack.agentdevice.runner', + ); +}); + +test('sessionless iOS runner host close is best effort', async () => { + mockCloseIosApp.mockRejectedValueOnce(new Error('terminate failed')); + + const result = await withSessionlessRunnerCleanup(undefined, iosSimulatorDevice, async () => { + return 'ok'; + }); + + expect(result).toBe('ok'); + expect(mockStopIosRunnerSession).toHaveBeenCalledWith(iosSimulatorDevice.id); + expect(mockCloseIosApp).toHaveBeenCalledWith( + iosSimulatorDevice, + 'com.callstack.agentdevice.runner', + ); +}); diff --git a/src/daemon/handlers/snapshot-session.ts b/src/daemon/handlers/snapshot-session.ts index b34f8ab94..3d199f468 100644 --- a/src/daemon/handlers/snapshot-session.ts +++ b/src/daemon/handlers/snapshot-session.ts @@ -1,5 +1,10 @@ import { resolveTargetDevice } from '../../core/dispatch.ts'; -import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts'; +import { + resolveRunnerAppBundleId, + stopIosRunnerSession, +} from '../../platforms/ios/runner-client.ts'; +import { closeIosApp } from '../../platforms/ios/apps.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { SessionStore } from '../session-store.ts'; @@ -28,10 +33,26 @@ export async function withSessionlessRunnerCleanup( // For multi-command flows, keep an active session via `open` so the runner can be reused. if (shouldCleanupSessionlessIosRunner) { await stopIosRunnerSession(device.id); + await closeSessionlessIosRunnerHostApp(device); } } } +async function closeSessionlessIosRunnerHostApp(device: SessionState['device']): Promise { + const bundleId = resolveRunnerAppBundleId(); + await closeIosApp(device, bundleId).catch((error) => { + emitDiagnostic({ + level: 'debug', + phase: 'ios_sessionless_runner_host_close_failed', + data: { + deviceId: device.id, + bundleId, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); +} + export function recordIfSession( sessionStore: SessionStore, session: SessionState | undefined, diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 7fcafd4a4..13b833d2a 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -675,6 +675,7 @@ export { resolveRunnerDestination, resolveRunnerBuildDestination, resolveRunnerMaxConcurrentDestinationsFlag, + resolveRunnerAppBundleId, resolveRunnerSigningBuildSettings, resolveRunnerBundleBuildSettings, assertSafeDerivedCleanup, diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index a4d745736..9e40a7572 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -114,7 +114,7 @@ function normalizeBundleId(value: string | undefined): string { return value?.trim() ?? ''; } -function resolveRunnerAppBundleId(env: NodeJS.ProcessEnv = process.env): string { +export function resolveRunnerAppBundleId(env: NodeJS.ProcessEnv = process.env): string { const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_BUNDLE_ID) || normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID); From cd4882201886ceb6f64fbf36ceaf59ffa362e244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 11:55:52 -0700 Subject: [PATCH 05/12] fix: require tracked app for ios snapshots --- .../__tests__/snapshot-handler.test.ts | 62 +++++++++++++++++++ src/daemon/snapshot-runtime.ts | 16 +++++ 2 files changed, 78 insertions(+) diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 1a4e976ef..bd487e8c9 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -336,6 +336,68 @@ test('snapshot rejects @ref scope without existing session snapshot', async () = } }); +test('snapshot on iOS rejects sessions without a tracked app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-sim-no-app'; + sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + req: { + token: 't', + session: sessionName, + command: 'snapshot', + positionals: [], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + if (response?.ok === false) { + expect(response.error.code).toBe('SESSION_NOT_FOUND'); + expect(response.error.message).toMatch(/iOS snapshot requires an active app session/i); + } + expect(mockDispatch).not.toHaveBeenCalled(); +}); + +test('snapshot on iOS runs when the session tracks an app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-sim-app'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, iosSimulatorDevice), + appBundleId: 'org.reactnavigation.playground', + }); + mockDispatch.mockResolvedValue({ + nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Home' }], + truncated: false, + backend: 'ios', + }); + + const response = await handleSnapshotCommands({ + req: { + token: 't', + session: sessionName, + command: 'snapshot', + positionals: [], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(mockDispatch).toHaveBeenCalledWith( + iosSimulatorDevice, + 'snapshot', + [], + undefined, + expect.objectContaining({ appBundleId: 'org.reactnavigation.playground' }), + ); +}); + test('snapshot surfaces filtered-to-zero Android guidance for interactive snapshots', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-empty-interactive'; diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index bd6cdcd68..86bd5f977 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -115,6 +115,8 @@ async function dispatchSnapshotRuntimeCommand( } const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return resolvedScope; + const iosAppSessionGuard = requireIosAppSessionForSnapshot(params.command, session, device); + if (iosAppSessionGuard) return iosAppSessionGuard; return await withSessionlessRunnerCleanup(session, device, async () => { const runtime = createSnapshotRuntime({ @@ -158,6 +160,20 @@ async function dispatchSnapshotRuntimeCommand( }); } +function requireIosAppSessionForSnapshot( + command: 'snapshot' | 'diff', + session: SessionState | undefined, + device: SessionState['device'], +): DaemonResponse | null { + if (device.platform !== 'ios' || session?.appBundleId) { + return null; + } + return errorResponse( + 'SESSION_NOT_FOUND', + `iOS ${command} requires an active app session on the target device. Run open first (for example: open --session ${session?.name ?? 'sim'} --platform ios --device "" ).`, + ); +} + function createSnapshotRuntime(params: { req: DaemonRequest; sessionName: string; From 39306913c4cedfdfe9c366d4e8723f5edb2b6e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 12:18:40 -0700 Subject: [PATCH 06/12] fix: keep in-page swipes on visible content --- src/__tests__/runtime-interactions.test.ts | 12 ++++++------ src/core/__tests__/dispatch-interactions.test.ts | 6 +++--- src/core/scroll-gesture.ts | 3 ++- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/__tests__/runtime-interactions.test.ts b/src/__tests__/runtime-interactions.test.ts index d8b021e9c..0d1868583 100644 --- a/src/__tests__/runtime-interactions.test.ts +++ b/src/__tests__/runtime-interactions.test.ts @@ -609,12 +609,12 @@ test('runtime gesture swipe presets use stable viewport lanes', async () => { session: 'default', }); - assert.deepEqual(pageSwipe.from, { x: 85, y: 65 }); - assert.deepEqual(pageSwipe.to, { x: 15, y: 65 }); + assert.deepEqual(pageSwipe.from, { x: 85, y: 50 }); + assert.deepEqual(pageSwipe.to, { x: 15, y: 50 }); assert.deepEqual(edgeSwipe.from, { x: 8, y: 50 }); assert.deepEqual(edgeSwipe.to, { x: 85, y: 50 }); assert.deepEqual(calls, [ - { from: { x: 85, y: 65 }, to: { x: 15, y: 65 }, durationMs: 300 }, + { from: { x: 85, y: 50 }, to: { x: 15, y: 50 }, durationMs: 300 }, { from: { x: 8, y: 50 }, to: { x: 85, y: 50 }, durationMs: 350 }, ]); }); @@ -634,9 +634,9 @@ test('runtime iOS in-page swipe presets avoid edge-navigation lanes', async () = session: 'default', }); - assert.deepEqual(pageSwipe.from, { x: 15, y: 65 }); - assert.deepEqual(pageSwipe.to, { x: 85, y: 65 }); - assert.deepEqual(calls, [{ from: { x: 15, y: 65 }, to: { x: 85, y: 65 }, durationMs: 300 }]); + assert.deepEqual(pageSwipe.from, { x: 15, y: 50 }); + assert.deepEqual(pageSwipe.to, { x: 85, y: 50 }); + assert.deepEqual(calls, [{ from: { x: 15, y: 50 }, to: { x: 85, y: 50 }, durationMs: 300 }]); }); test('runtime viewport gestures reject inspect-only macOS surfaces', async () => { diff --git a/src/core/__tests__/dispatch-interactions.test.ts b/src/core/__tests__/dispatch-interactions.test.ts index b1c42fdcf..5975479eb 100644 --- a/src/core/__tests__/dispatch-interactions.test.ts +++ b/src/core/__tests__/dispatch-interactions.test.ts @@ -116,12 +116,12 @@ test('handleSwipePresetCommand resolves Android in-page swipe to content lane', undefined, ); - assert.deepEqual(calls, [[340, 520, 60, 520, 300]]); + assert.deepEqual(calls, [[340, 400, 60, 400, 300]]); assert.deepEqual(result, { x1: 340, - y1: 520, + y1: 400, x2: 60, - y2: 520, + y2: 400, preset: 'left', durationMs: 300, effectiveDurationMs: 300, diff --git a/src/core/scroll-gesture.ts b/src/core/scroll-gesture.ts index 5aa161b3f..b63e42327 100644 --- a/src/core/scroll-gesture.ts +++ b/src/core/scroll-gesture.ts @@ -109,7 +109,8 @@ export function buildSwipePresetGesturePlan( options: { platform?: string; marginPx?: number } = {}, ): SwipePresetGesturePlan { const marginPx = options.marginPx ?? 8; - const horizontalLanePercent = 65; + // Mid-screen keeps in-page swipes on visible content; lower lanes can land in blank pager space. + const horizontalLanePercent = 50; const inPageStartPercent = 85; const inPageEndPercent = 15; const [startPercent, endPercent, yPercent] = From 87f212bd6c0cbbfb8b9182c3e7393a5fb35e9d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 12:25:23 -0700 Subject: [PATCH 07/12] test: align lock policy probes with ios snapshot guard --- .../request-router-lock-policy.test.ts | 97 +++++++++++-------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index 5477da9b0..af2238472 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -39,6 +39,22 @@ function makeIosSession(name: string): SessionState { }; } +function makeAndroidSession(name: string, id = 'emulator-5554'): SessionState { + return { + name, + createdAt: Date.now(), + actions: [], + device: { + platform: 'android', + target: 'mobile', + id, + name: id === 'emulator-5554' ? 'Pixel 9 Pro XL' : 'Pixel 8', + kind: 'emulator', + booted: true, + }, + }; +} + beforeEach(() => { mockDispatch.mockReset(); mockDispatch.mockResolvedValue({ nodes: [] }); @@ -112,7 +128,7 @@ test('direct daemon requests cannot bypass reject lock policy for existing sessi } }); -test('fresh named sessions with matching explicit udid bind and serialize on the selected device', async () => { +test('fresh named sessions with matching explicit serial bind and serialize on the selected device', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); const dispatchGate = installGatedDispatch(); @@ -121,54 +137,54 @@ test('fresh named sessions with matching explicit udid bind and serialize on the token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), - deviceInventoryProvider: async () => [makeIosSession('inventory').device], + deviceInventoryProvider: async () => [makeAndroidSession('inventory').device], trackDownloadableArtifact: () => 'artifact-id', }); const first = handler({ token: 'test-token', - session: 'qa-ios-a', + session: 'qa-android-a', command: 'snapshot', positionals: [], flags: { - udid: 'SIM-001', + serial: 'emulator-5554', }, meta: { requestId: 'req-fresh-lock-a', lockPolicy: 'reject', - lockPlatform: 'ios', + lockPlatform: 'android', }, }); await vi.waitFor(() => { - expect(dispatchGate.order).toEqual(['start-snapshot-SIM-001']); + expect(dispatchGate.order).toEqual(['start-snapshot-emulator-5554']); }); const second = handler({ token: 'test-token', - session: 'qa-ios-b', + session: 'qa-android-b', command: 'snapshot', positionals: [], flags: { - udid: 'SIM-001', + serial: 'emulator-5554', }, meta: { requestId: 'req-fresh-lock-b', lockPolicy: 'reject', - lockPlatform: 'ios', + lockPlatform: 'android', }, }); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(dispatchGate.order).toEqual(['start-snapshot-SIM-001']); + expect(dispatchGate.order).toEqual(['start-snapshot-emulator-5554']); dispatchGate.releaseNext(); await vi.waitFor(() => { expect(dispatchGate.order).toEqual([ - 'start-snapshot-SIM-001', - 'end-snapshot-SIM-001', - 'start-snapshot-SIM-001', + 'start-snapshot-emulator-5554', + 'end-snapshot-emulator-5554', + 'start-snapshot-emulator-5554', ]); }); @@ -179,17 +195,15 @@ test('fresh named sessions with matching explicit udid bind and serialize on the expect(firstResponse.ok).toBe(true); expect(secondResponse.ok).toBe(true); expect(dispatchGate.getMaxActive()).toBe(1); - expect(sessionStore.get('qa-ios-a')?.device.id).toBe('SIM-001'); - expect(sessionStore.get('qa-ios-b')?.device.id).toBe('SIM-001'); + expect(sessionStore.get('qa-android-a')?.device.id).toBe('emulator-5554'); + expect(sessionStore.get('qa-android-b')?.device.id).toBe('emulator-5554'); }); test('fresh named sessions with the same name serialize first binding before rejecting another device', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); - const firstDevice = makeIosSession('inventory').device; + const firstDevice = makeAndroidSession('inventory').device; const secondDevice: SessionState['device'] = { - ...firstDevice, - id: 'SIM-002', - name: 'iPhone 17', + ...makeAndroidSession('inventory-2', 'emulator-5556').device, }; const dispatchGate = installGatedDispatch(); @@ -204,40 +218,40 @@ test('fresh named sessions with the same name serialize first binding before rej const first = handler({ token: 'test-token', - session: 'qa-ios', + session: 'qa-android', command: 'snapshot', positionals: [], flags: { - udid: 'SIM-001', + serial: 'emulator-5554', }, meta: { requestId: 'req-fresh-same-session-a', lockPolicy: 'reject', - lockPlatform: 'ios', + lockPlatform: 'android', }, }); await vi.waitFor(() => { - expect(dispatchGate.order).toEqual(['start-snapshot-SIM-001']); + expect(dispatchGate.order).toEqual(['start-snapshot-emulator-5554']); }); const second = handler({ token: 'test-token', - session: 'qa-ios', + session: 'qa-android', command: 'snapshot', positionals: [], flags: { - udid: 'SIM-002', + serial: 'emulator-5556', }, meta: { requestId: 'req-fresh-same-session-b', lockPolicy: 'reject', - lockPlatform: 'ios', + lockPlatform: 'android', }, }); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(dispatchGate.order).toEqual(['start-snapshot-SIM-001']); + expect(dispatchGate.order).toEqual(['start-snapshot-emulator-5554']); dispatchGate.releaseNext(); @@ -247,12 +261,15 @@ test('fresh named sessions with the same name serialize first binding before rej expect(secondResponse.ok).toBe(false); if (!secondResponse.ok) { expect(secondResponse.error.code).toBe('INVALID_ARGS'); - expect(secondResponse.error.message).toMatch(/--udid=SIM-002/i); + expect(secondResponse.error.message).toMatch(/--serial=emulator-5556/i); } - expect(dispatchGate.order).toEqual(['start-snapshot-SIM-001', 'end-snapshot-SIM-001']); + expect(dispatchGate.order).toEqual([ + 'start-snapshot-emulator-5554', + 'end-snapshot-emulator-5554', + ]); expect(dispatchGate.getMaxActive()).toBe(1); expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(sessionStore.get('qa-ios')?.device.id).toBe('SIM-001'); + expect(sessionStore.get('qa-android')?.device.id).toBe('emulator-5554'); }); test('fresh named sessions with only lock platform default serialize on the selected device', async () => { @@ -264,7 +281,7 @@ test('fresh named sessions with only lock platform default serialize on the sele token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), - deviceInventoryProvider: async () => [makeIosSession('inventory').device], + deviceInventoryProvider: async () => [makeAndroidSession('inventory').device], trackDownloadableArtifact: () => 'artifact-id', }); @@ -277,12 +294,12 @@ test('fresh named sessions with only lock platform default serialize on the sele meta: { requestId: 'req-fresh-default-lock-a', lockPolicy: 'reject', - lockPlatform: 'ios', + lockPlatform: 'android', }, }); await vi.waitFor(() => { - expect(dispatchGate.order).toEqual(['start-snapshot-SIM-001']); + expect(dispatchGate.order).toEqual(['start-snapshot-emulator-5554']); }); const second = handler({ @@ -294,20 +311,20 @@ test('fresh named sessions with only lock platform default serialize on the sele meta: { requestId: 'req-fresh-default-lock-b', lockPolicy: 'reject', - lockPlatform: 'ios', + lockPlatform: 'android', }, }); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(dispatchGate.order).toEqual(['start-snapshot-SIM-001']); + expect(dispatchGate.order).toEqual(['start-snapshot-emulator-5554']); dispatchGate.releaseNext(); await vi.waitFor(() => { expect(dispatchGate.order).toEqual([ - 'start-snapshot-SIM-001', - 'end-snapshot-SIM-001', - 'start-snapshot-SIM-001', + 'start-snapshot-emulator-5554', + 'end-snapshot-emulator-5554', + 'start-snapshot-emulator-5554', ]); }); @@ -318,8 +335,8 @@ test('fresh named sessions with only lock platform default serialize on the sele expect(firstResponse.ok).toBe(true); expect(secondResponse.ok).toBe(true); expect(dispatchGate.getMaxActive()).toBe(1); - expect(sessionStore.get('qa-default-a')?.device.id).toBe('SIM-001'); - expect(sessionStore.get('qa-default-b')?.device.id).toBe('SIM-001'); + expect(sessionStore.get('qa-default-a')?.device.id).toBe('emulator-5554'); + expect(sessionStore.get('qa-default-b')?.device.id).toBe('emulator-5554'); }); test('fresh named sessions reject incompatible selector combinations before binding', async () => { From beba0835cc3b176b72647e62ae7d387a394d7757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 15:15:17 -0700 Subject: [PATCH 08/12] fix: compose post-gesture snapshots with android freshness --- .../interaction-outcome-policy.test.ts | 6 +- .../__tests__/snapshot-handler.test.ts | 74 +++++++++++++++++++ src/daemon/handlers/find.ts | 4 +- src/daemon/handlers/interaction-common.ts | 4 +- src/daemon/handlers/snapshot-capture.ts | 39 +++++++--- src/daemon/interaction-outcome-policy.ts | 4 +- src/platforms/ios/runner-client.ts | 7 +- src/platforms/ios/runner-session.ts | 4 +- 8 files changed, 115 insertions(+), 27 deletions(-) diff --git a/src/daemon/__tests__/interaction-outcome-policy.test.ts b/src/daemon/__tests__/interaction-outcome-policy.test.ts index 408a487d3..534e48f4e 100644 --- a/src/daemon/__tests__/interaction-outcome-policy.test.ts +++ b/src/daemon/__tests__/interaction-outcome-policy.test.ts @@ -5,7 +5,7 @@ import { buildInteractionSurfaceSignature, classifyInteractionSurfaceChange, markPendingInteractionOutcome, - stripInternalInteractionOutcomeFlags, + stripInternalInteractionFlags, } from '../interaction-outcome-policy.ts'; import type { SessionState } from '../types.ts'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; @@ -84,9 +84,9 @@ test('markPendingInteractionOutcome stores retry state only for explicit retry f assert.equal(longPressSession.pendingInteractionOutcome, undefined); }); -test('stripInternalInteractionOutcomeFlags removes internal interaction controls', () => { +test('stripInternalInteractionFlags removes internal interaction controls', () => { assert.deepEqual( - stripInternalInteractionOutcomeFlags({ + stripInternalInteractionFlags({ platform: 'ios', interactionOutcome: { retryOnNoChange: true }, postGestureStabilization: true, diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index bd487e8c9..75da0691c 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -1032,6 +1032,80 @@ test('captureSnapshot retries pending tap outcome before post-gesture stabilizat expect(session.postGestureStabilization).toBeUndefined(); }); +test('captureSnapshot composes post-gesture stabilization with Android freshness capture', async () => { + const sessionName = 'android-post-gesture-freshness'; + const session = makeSession(sessionName, androidDevice); + const baselineNodes = Array.from({ length: 18 }, (_, index) => ({ + ref: `e${index + 1}`, + index, + depth: 0, + type: 'android.widget.TextView', + label: `Inbox row ${index + 1}`, + })); + const changedNodes = Array.from({ length: 18 }, (_, index) => ({ + ref: `e${index + 1}`, + index, + depth: 0, + type: 'android.widget.TextView', + label: index === 0 ? 'album-0' : `Album row ${index + 1}`, + })); + session.snapshot = { + nodes: baselineNodes, + createdAt: Date.now(), + backend: 'android', + comparisonSafe: true, + }; + session.androidSnapshotFreshness = { + action: 'click', + markedAt: Date.now(), + baselineCount: baselineNodes.length, + baselineSignatures: buildSnapshotSignatures(baselineNodes), + routeComparable: true, + }; + session.postGestureStabilization = { + action: 'click', + markedAt: Date.now(), + }; + + mockDispatch + .mockResolvedValueOnce({ + nodes: baselineNodes, + truncated: false, + backend: 'android', + analysis: { rawNodeCount: 18, maxDepth: 1 }, + }) + .mockResolvedValueOnce({ + nodes: changedNodes, + truncated: false, + backend: 'android', + analysis: { rawNodeCount: 18, maxDepth: 1 }, + }) + .mockResolvedValueOnce({ + nodes: changedNodes, + truncated: false, + backend: 'android', + analysis: { rawNodeCount: 18, maxDepth: 1 }, + }); + + const result = await captureSnapshot({ + device: androidDevice, + session, + flags: { snapshotInteractiveOnly: true }, + logPath: '/tmp/daemon.log', + }); + + expect(result.snapshot.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'album-0' })]), + ); + expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual([ + 'snapshot', + 'snapshot', + 'snapshot', + ]); + expect(session.androidSnapshotFreshness).toBeUndefined(); + expect(session.postGestureStabilization).toBeUndefined(); +}); + test('captureSnapshot composes pending outcome retry with Android freshness capture', async () => { const sessionName = 'android-lazy-outcome-freshness'; const session = makeSession(sessionName, androidDevice); diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index b11366885..b530d63c7 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -16,7 +16,7 @@ import { captureSnapshot } from './snapshot-capture.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; import { errorResponse } from './response.ts'; import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts'; -import { stripInternalInteractionOutcomeFlags } from '../interaction-outcome-policy.ts'; +import { stripInternalInteractionFlags } from '../interaction-outcome-policy.ts'; import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; @@ -502,7 +502,7 @@ function recordFindAction(ctx: FindContext, match: ResolvedMatch, action: string // --- Helpers --- function publicFindFlags(flags: DaemonRequest['flags']): Record { - return { ...(stripInternalInteractionOutcomeFlags(flags) ?? {}) }; + return { ...(stripInternalInteractionFlags(flags) ?? {}) }; } function buildAmbiguousMatchError( diff --git a/src/daemon/handlers/interaction-common.ts b/src/daemon/handlers/interaction-common.ts index 2667350e6..046c749f5 100644 --- a/src/daemon/handlers/interaction-common.ts +++ b/src/daemon/handlers/interaction-common.ts @@ -11,7 +11,7 @@ import { } from '../android-snapshot-freshness.ts'; import { markPendingInteractionOutcome, - stripInternalInteractionOutcomeFlags, + stripInternalInteractionFlags, } from '../interaction-outcome-policy.ts'; import { markPostGestureStabilization } from '../post-gesture-stabilization.ts'; @@ -99,7 +99,7 @@ export function finalizeTouchInteraction(params: { actionFinishedAt, androidFreshnessBaseline, } = params; - const actionFlags = stripInternalInteractionOutcomeFlags(flags); + const actionFlags = stripInternalInteractionFlags(flags); sessionStore.recordAction(session, { command, positionals, diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index ccb399706..a500f965e 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -39,7 +39,6 @@ import { type InteractionSurfaceChange, } from '../interaction-outcome-policy.ts'; import { - capturePostGestureStabilizedSnapshot, capturePostGestureStabilizedResult, } from '../post-gesture-stabilization.ts'; import { findNodeByLabel, pruneGroupNodes, resolveRefLabel } from '../snapshot-processing.ts'; @@ -91,12 +90,7 @@ export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{ (params.device.platform === 'ios' || params.device.platform === 'android') && params.session?.postGestureStabilization ) { - return { - snapshot: await capturePostGestureStabilizedSnapshot({ - session: params.session, - capture: async () => (await captureSnapshotAttempt(params)).snapshot, - }), - }; + return await capturePostGestureAwareSnapshot({ ...params, session: params.session }); } const freshness = getActiveAndroidSnapshotFreshness(params.session); if (freshness && params.device.platform === 'android') { @@ -127,7 +121,7 @@ async function captureInteractionOutcomeAwareSnapshot( let settled = await waitForDelayedInteractionSurfaceChange( params, pending, - await captureSnapshotAttemptForInteractionOutcome(params), + await capturePostActionSnapshotAttempt(params), ); let latest = settled.latest; let outcome = await retryPendingInteractionOutcome({ @@ -142,7 +136,7 @@ async function captureInteractionOutcomeAwareSnapshot( settled = await waitForDelayedInteractionSurfaceChange( params, pending, - await captureSnapshotAttemptForInteractionOutcome(params), + await capturePostActionSnapshotAttempt(params), ); latest = settled.latest; outcome = await retryPendingInteractionOutcome({ @@ -157,7 +151,7 @@ async function captureInteractionOutcomeAwareSnapshot( latest = await capturePostGestureStabilizedResult({ session, initial: latest, - capture: async () => await captureSnapshotAttemptForInteractionOutcome(params), + capture: async () => await capturePostActionSnapshotAttempt(params), readSnapshot: (attempt) => attempt.snapshot, }); if (outcome.change !== 'ambiguous' && latest.freshness?.staleAfterRetries !== true) { @@ -195,7 +189,7 @@ async function waitForDelayedInteractionSurfaceChange( if (change !== 'unchanged') return { latest, change }; await sleep(INTERACTION_CHANGE_RECHECK_DELAY_MS); - latest = await captureSnapshotAttemptForInteractionOutcome(params); + latest = await capturePostActionSnapshotAttempt(params); change = classifyInteractionSurfaceChange( pending.preSignature, buildInteractionSurfaceSignature(latest.snapshot.nodes), @@ -292,7 +286,28 @@ async function captureAndroidFreshnessAwareAttempt( }; } -async function captureSnapshotAttemptForInteractionOutcome( +async function capturePostGestureAwareSnapshot( + params: CaptureSnapshotParams & { session: SessionState }, +): Promise<{ + snapshot: SnapshotState; + analysis?: AndroidSnapshotAnalysis; + androidSnapshot?: AndroidSnapshotBackendMetadata; + freshness?: AndroidFreshnessCaptureMeta; +}> { + const latest = await capturePostGestureStabilizedResult({ + session: params.session, + capture: async () => await capturePostActionSnapshotAttempt(params), + readSnapshot: (attempt) => attempt.snapshot, + }); + return { + snapshot: latest.snapshot, + analysis: latest.data.analysis, + androidSnapshot: latest.data.androidSnapshot, + freshness: latest.freshness, + }; +} + +async function capturePostActionSnapshotAttempt( params: CaptureSnapshotParams & { session: SessionState }, ): Promise { const freshness = getActiveAndroidSnapshotFreshness(params.session); diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-outcome-policy.ts index 78e970e81..1f3edd98f 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-outcome-policy.ts @@ -37,7 +37,7 @@ export function markPendingInteractionOutcome(params: { action: command, command: retryCommand, positionals, - flags: stripInternalInteractionOutcomeFlags(flags), + flags: stripInternalInteractionFlags(flags), markedAt: Date.now(), attemptsRemaining: OUTCOME_RETRY_ATTEMPTS, preSignature, @@ -134,7 +134,7 @@ export function emitInteractionSettleTimeout(params: { }); } -export function stripInternalInteractionOutcomeFlags( +export function stripInternalInteractionFlags( flags: CommandFlags | undefined, ): CommandFlags | undefined { if (!flags?.interactionOutcome && !flags?.postGestureStabilization) return flags; diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 13b833d2a..59619d827 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -3,7 +3,7 @@ import { withRetry } from '../../utils/retry.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { getRequestSignal } from '../../daemon/request-cancel.ts'; -import { RUNNER_COMMAND_TIMEOUT_MS, RUNNER_STARTUP_TIMEOUT_MS } from './runner-transport.ts'; +import { RUNNER_COMMAND_TIMEOUT_MS } from './runner-transport.ts'; import { type RunnerSessionOptions, type RunnerSession, @@ -12,6 +12,7 @@ import { stopIosRunnerSession, validateRunnerDevice, executeRunnerCommandWithSession, + readRunnerStartupTimeoutMs, } from './runner-session.ts'; import { assertRunnerRequestActive, @@ -250,10 +251,6 @@ async function executeRunnerCommand( } } -function readRunnerStartupTimeoutMs(session: Pick): number { - return session.startupTimeoutMs ?? RUNNER_STARTUP_TIMEOUT_MS; -} - async function handleRunnerTransportErrorAfterCommandSend( device: DeviceInfo, session: RunnerSession, diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index d24ad6a35..cd232f7cd 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -703,7 +703,9 @@ function markRunnerPreflightError(error: unknown, details: Record): number { +export function readRunnerStartupTimeoutMs( + session: Pick, +): number { return session.startupTimeoutMs ?? RUNNER_STARTUP_TIMEOUT_MS; } From 994687db4e3de0b8df28f9263765b10cff6035ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 15:17:36 -0700 Subject: [PATCH 09/12] refactor: clarify post-action snapshot policies --- src/daemon/handlers/snapshot-capture.ts | 58 ++++++++++++------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index a500f965e..ba5b3c8c2 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -69,16 +69,33 @@ type SnapshotAttempt = { freshness?: AndroidFreshnessCaptureMeta; }; -type AndroidFreshnessReason = 'empty-interactive' | 'sharp-drop' | 'stuck-route'; -type AndroidFreshnessMode = 'default' | 'ref-refresh'; -const INTERACTION_CHANGE_RECHECK_DELAY_MS = 500; - -export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{ +type CaptureSnapshotResult = { snapshot: SnapshotState; analysis?: AndroidSnapshotAnalysis; androidSnapshot?: AndroidSnapshotBackendMetadata; freshness?: AndroidFreshnessCaptureMeta; -}> { +}; + +type AndroidFreshnessReason = 'empty-interactive' | 'sharp-drop' | 'stuck-route'; +type AndroidFreshnessMode = 'default' | 'ref-refresh'; +const INTERACTION_CHANGE_RECHECK_DELAY_MS = 500; + +export async function captureSnapshot(params: CaptureSnapshotParams): Promise { + const postActionResult = await capturePostActionAwareSnapshot(params); + if (postActionResult) return postActionResult; + + const data = await captureSnapshotData(params); + clearAndroidSnapshotFreshness(params.session); + return { + snapshot: buildSnapshotState(data, resolveSnapshotStateFlags(params)), + analysis: data.analysis, + androidSnapshot: data.androidSnapshot, + }; +} + +async function capturePostActionAwareSnapshot( + params: CaptureSnapshotParams, +): Promise { const pendingInteractionOutcome = getActivePendingInteractionOutcome(params.session); if (pendingInteractionOutcome && params.session) { return await captureInteractionOutcomeAwareSnapshot( @@ -96,24 +113,13 @@ export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{ if (freshness && params.device.platform === 'android') { return await captureAndroidFreshnessAwareSnapshot(params, freshness); } - const data = await captureSnapshotData(params); - clearAndroidSnapshotFreshness(params.session); - return { - snapshot: buildSnapshotState(data, resolveSnapshotStateFlags(params)), - analysis: data.analysis, - androidSnapshot: data.androidSnapshot, - }; + return undefined; } async function captureInteractionOutcomeAwareSnapshot( params: CaptureSnapshotParams & { session: SessionState }, pending: NonNullable, -): Promise<{ - snapshot: SnapshotState; - analysis?: AndroidSnapshotAnalysis; - androidSnapshot?: AndroidSnapshotBackendMetadata; - freshness?: AndroidFreshnessCaptureMeta; -}> { +): Promise { const session = params.session; const startedAt = Date.now(); @@ -234,12 +240,7 @@ export async function captureSnapshotData(params: CaptureSnapshotParams): Promis async function captureAndroidFreshnessAwareSnapshot( params: CaptureSnapshotParams, freshness: NonNullable, -): Promise<{ - snapshot: SnapshotState; - analysis?: AndroidSnapshotAnalysis; - androidSnapshot?: AndroidSnapshotBackendMetadata; - freshness?: AndroidFreshnessCaptureMeta; -}> { +): Promise { const latest = await captureAndroidFreshnessAwareAttempt(params, freshness); return { snapshot: latest.snapshot, @@ -288,12 +289,7 @@ async function captureAndroidFreshnessAwareAttempt( async function capturePostGestureAwareSnapshot( params: CaptureSnapshotParams & { session: SessionState }, -): Promise<{ - snapshot: SnapshotState; - analysis?: AndroidSnapshotAnalysis; - androidSnapshot?: AndroidSnapshotBackendMetadata; - freshness?: AndroidFreshnessCaptureMeta; -}> { +): Promise { const latest = await capturePostGestureStabilizedResult({ session: params.session, capture: async () => await capturePostActionSnapshotAttempt(params), From 02ed53e4b590e1529c5e5a0addc3aba5718aa6c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 15:21:08 -0700 Subject: [PATCH 10/12] fix: avoid redundant swipe stabilization flag --- .../__tests__/runtime-interactions.test.ts | 2 +- src/compat/maestro/runtime-interactions.ts | 4 ---- src/daemon/handlers/snapshot-capture.ts | 23 +++++++------------ 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/compat/maestro/__tests__/runtime-interactions.test.ts b/src/compat/maestro/__tests__/runtime-interactions.test.ts index 8f00546ec..68713933d 100644 --- a/src/compat/maestro/__tests__/runtime-interactions.test.ts +++ b/src/compat/maestro/__tests__/runtime-interactions.test.ts @@ -196,7 +196,7 @@ test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async ( expect(response.ok).toBe(true); expect(swipes).toEqual([['200', '600', '200', '280', '300']]); - expect(swipeFlags[0]?.postGestureStabilization).toBe(true); + expect(swipeFlags[0]?.postGestureStabilization).toBeUndefined(); }); test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the content lane', async () => { diff --git a/src/compat/maestro/runtime-interactions.ts b/src/compat/maestro/runtime-interactions.ts index e063a044f..9e658d33d 100644 --- a/src/compat/maestro/runtime-interactions.ts +++ b/src/compat/maestro/runtime-interactions.ts @@ -239,10 +239,6 @@ async function invokeSwipeGesture( String(swipe.end.y), ...(durationMs ? [durationMs] : []), ], - flags: { - ...params.baseReq.flags, - postGestureStabilization: true, - }, }); } diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index ba5b3c8c2..7239c981d 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -36,7 +36,6 @@ import { emitInteractionSettleTimeout, getActivePendingInteractionOutcome, retryPendingInteractionOutcome, - type InteractionSurfaceChange, } from '../interaction-outcome-policy.ts'; import { capturePostGestureStabilizedResult, @@ -124,32 +123,30 @@ async function captureInteractionOutcomeAwareSnapshot( const startedAt = Date.now(); let retryAttempts = 0; - let settled = await waitForDelayedInteractionSurfaceChange( + let latest = await waitForDelayedInteractionSurfaceChange( params, pending, await capturePostActionSnapshotAttempt(params), ); - let latest = settled.latest; let outcome = await retryPendingInteractionOutcome({ session, pending, logPath: params.logPath, - snapshot: settled.latest.snapshot, + snapshot: latest.snapshot, }); while (outcome.retried) { retryAttempts += 1; - settled = await waitForDelayedInteractionSurfaceChange( + latest = await waitForDelayedInteractionSurfaceChange( params, pending, await capturePostActionSnapshotAttempt(params), ); - latest = settled.latest; outcome = await retryPendingInteractionOutcome({ session, pending, logPath: params.logPath, - snapshot: settled.latest.snapshot, + snapshot: latest.snapshot, }); } @@ -186,22 +183,18 @@ async function waitForDelayedInteractionSurfaceChange( params: CaptureSnapshotParams & { session: SessionState }, pending: NonNullable, initial: SnapshotAttempt, -): Promise<{ latest: SnapshotAttempt; change: InteractionSurfaceChange }> { +): Promise { let latest = initial; - let change = classifyInteractionSurfaceChange( + const change = classifyInteractionSurfaceChange( pending.preSignature, buildInteractionSurfaceSignature(latest.snapshot.nodes), ); - if (change !== 'unchanged') return { latest, change }; + if (change !== 'unchanged') return latest; await sleep(INTERACTION_CHANGE_RECHECK_DELAY_MS); latest = await capturePostActionSnapshotAttempt(params); - change = classifyInteractionSurfaceChange( - pending.preSignature, - buildInteractionSurfaceSignature(latest.snapshot.nodes), - ); - return { latest, change }; + return latest; } export async function captureSnapshotData(params: CaptureSnapshotParams): Promise { From db176592157ad19cc835c151fdb6436f29ea776c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 17:38:42 -0700 Subject: [PATCH 11/12] fix: route android swipes through gesture helper --- android-multitouch-helper/README.md | 4 +- .../MultiTouchInstrumentation.java | 68 +++++++++++++++++-- src/core/interactors/android.ts | 11 +-- .../__tests__/multitouch-helper.test.ts | 42 +++++++++++- src/platforms/android/adb-executor.ts | 8 +++ src/platforms/android/multitouch-helper.ts | 56 ++++++++++++++- 6 files changed, 176 insertions(+), 13 deletions(-) diff --git a/android-multitouch-helper/README.md b/android-multitouch-helper/README.md index c23fe15bc..320c52ad7 100644 --- a/android-multitouch-helper/README.md +++ b/android-multitouch-helper/README.md @@ -1,6 +1,6 @@ # Android MultiTouch Helper -Small instrumentation APK used to inject Android two-pointer gestures through +Small instrumentation APK used to inject Android touch gestures through `UiAutomation.injectInputEvent`. The helper accepts a compact base64 JSON payload so local ADB, remote ADB tunnels, and remote providers that allow `adb install -t` plus `am instrument` can use the same contract. @@ -34,7 +34,7 @@ Successful results include: - `ok=true` - `helperApiVersion=1` -- `kind` (`pinch`, `rotate`, or `transform`) +- `kind` (`swipe`, `pinch`, `rotate`, or `transform`) - `injectedEvents` - `elapsedMs` diff --git a/android-multitouch-helper/src/main/java/com/callstack/agentdevice/multitouchhelper/MultiTouchInstrumentation.java b/android-multitouch-helper/src/main/java/com/callstack/agentdevice/multitouchhelper/MultiTouchInstrumentation.java index 4f5d839bd..e33aaabcc 100644 --- a/android-multitouch-helper/src/main/java/com/callstack/agentdevice/multitouchhelper/MultiTouchInstrumentation.java +++ b/android-multitouch-helper/src/main/java/com/callstack/agentdevice/multitouchhelper/MultiTouchInstrumentation.java @@ -66,13 +66,18 @@ private GestureSpec readSpec(Bundle arguments) throws Exception { throw new IllegalArgumentException("Unsupported protocol: " + protocol); } String kind = payload.getString("kind"); - if (!"pinch".equals(kind) && !"rotate".equals(kind) && !"transform".equals(kind)) { + if (!"swipe".equals(kind) + && !"pinch".equals(kind) + && !"rotate".equals(kind) + && !"transform".equals(kind)) { throw new IllegalArgumentException("Unsupported kind: " + kind); } - int x = payload.getInt("x"); - int y = payload.getInt("y"); + int x = "swipe".equals(kind) ? payload.getInt("x1") : payload.getInt("x"); + int y = "swipe".equals(kind) ? payload.getInt("y1") : payload.getInt("y"); int dx = payload.optInt("dx", 0); int dy = payload.optInt("dy", 0); + int x2 = payload.optInt("x2", x + dx); + int y2 = payload.optInt("y2", y + dy); int durationMs = clamp(payload.optInt("durationMs", 300), MIN_DURATION_MS, MAX_DURATION_MS); int radius = clamp(payload.optInt("radius", DEFAULT_RADIUS), MIN_RADIUS, MAX_RADIUS); double scale = payload.optDouble("scale", 1.0d); @@ -83,10 +88,13 @@ private GestureSpec readSpec(Bundle arguments) throws Exception { if (("rotate".equals(kind) || "transform".equals(kind)) && !isFinite(degrees)) { throw new IllegalArgumentException("Degrees must be finite"); } - return new GestureSpec(kind, x, y, dx, dy, durationMs, scale, degrees, radius); + return new GestureSpec(kind, x, y, dx, dy, x2, y2, durationMs, scale, degrees, radius); } private int injectGesture(GestureSpec spec) { + if ("swipe".equals(spec.kind)) { + return injectSinglePointerGesture(spec); + } UiAutomation automation = getUiAutomation(); long downTime = SystemClock.uptimeMillis(); long eventTime = downTime; @@ -149,6 +157,47 @@ private int injectGesture(GestureSpec spec) { } } + private int injectSinglePointerGesture(GestureSpec spec) { + UiAutomation automation = getUiAutomation(); + long downTime = SystemClock.uptimeMillis(); + long eventTime = downTime; + PointerPair activePointer = pointerPairAt(spec, 0); + int count = 0; + + try { + inject( + automation, + motionEvent(downTime, eventTime, MotionEvent.ACTION_DOWN, activePointer), + true); + count += 1; + + int frameCount = + Math.max(3, Math.round(spec.durationMs / (float) MOVE_FRAME_INTERVAL_MS)); + for (int index = 1; index < frameCount; index += 1) { + double t = (double) index / (double) frameCount; + PointerPair frame = pointerPairAt(spec, t); + eventTime = downTime + Math.round(spec.durationMs * t); + inject(automation, motionEvent(downTime, eventTime, MotionEvent.ACTION_MOVE, frame), false); + count += 1; + activePointer = frame; + } + + eventTime = downTime + spec.durationMs; + activePointer = pointerPairAt(spec, 1); + inject( + automation, + motionEvent(downTime, eventTime, MotionEvent.ACTION_UP, activePointer), + true); + count += 1; + return count; + } catch (RuntimeException error) { + if (count > 0) { + injectCancel(automation, downTime, eventTime + 16, activePointer); + } + throw error; + } + } + private static void inject(UiAutomation automation, MotionEvent event, boolean waitForDispatch) { try { if (!automation.injectInputEvent(event, waitForDispatch)) { @@ -203,6 +252,11 @@ private static MotionEvent motionEvent(long downTime, long eventTime, int action } private static PointerPair pointerPairAt(GestureSpec spec, double t) { + if ("swipe".equals(spec.kind)) { + return new PointerPair( + new float[] {(float) (spec.x + (spec.x2 - spec.x) * t)}, + new float[] {(float) (spec.y + (spec.y2 - spec.y) * t)}); + } if ("pinch".equals(spec.kind)) { double startRadius = spec.radius / Math.max(spec.scale, 1.0d); double endRadius = spec.radius; @@ -255,6 +309,8 @@ private static final class GestureSpec { final int y; final int dx; final int dy; + final int x2; + final int y2; final int durationMs; final double scale; final double degrees; @@ -266,6 +322,8 @@ private static final class GestureSpec { int y, int dx, int dy, + int x2, + int y2, int durationMs, double scale, double degrees, @@ -275,6 +333,8 @@ private static final class GestureSpec { this.y = y; this.dx = dx; this.dy = dy; + this.x2 = x2; + this.y2 = y2; this.durationMs = durationMs; this.scale = scale; this.degrees = degrees; diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 871024481..f29adf023 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -13,12 +13,12 @@ import { pressAndroid, rotateAndroid, scrollAndroid, - swipeAndroid, typeAndroid, } from '../../platforms/android/input-actions.ts'; import { pinchAndroid, rotateGestureAndroid, + swipeGestureAndroid, transformGestureAndroid, } from '../../platforms/android/multitouch-helper.ts'; import { @@ -48,9 +48,12 @@ export function createAndroidInteractor(device: DeviceInfo): Interactor { await pressAndroid(device, x, y); await pressAndroid(device, x, y); }, - swipe: (x1, y1, x2, y2, durationMs) => swipeAndroid(device, x1, y1, x2, y2, durationMs), - pan: (x1, y1, x2, y2, durationMs) => swipeAndroid(device, x1, y1, x2, y2, durationMs), - fling: (x1, y1, x2, y2, durationMs) => swipeAndroid(device, x1, y1, x2, y2, durationMs), + swipe: (x1, y1, x2, y2, durationMs) => + swipeGestureAndroid(device, { x1, y1, x2, y2, durationMs }), + pan: (x1, y1, x2, y2, durationMs) => + swipeGestureAndroid(device, { x1, y1, x2, y2, durationMs }), + fling: (x1, y1, x2, y2, durationMs) => + swipeGestureAndroid(device, { x1, y1, x2, y2, durationMs }), longPress: (x, y, durationMs) => longPressAndroid(device, x, y, durationMs), focus: (x, y) => focusAndroid(device, x, y), type: (text, delayMs) => typeAndroid(device, text, delayMs), diff --git a/src/platforms/android/__tests__/multitouch-helper.test.ts b/src/platforms/android/__tests__/multitouch-helper.test.ts index 7c342a552..17586f035 100644 --- a/src/platforms/android/__tests__/multitouch-helper.test.ts +++ b/src/platforms/android/__tests__/multitouch-helper.test.ts @@ -12,6 +12,7 @@ import { resetAndroidMultiTouchHelperInstallCache, rotateGestureAndroid, runAndroidMultiTouchHelperGesture, + swipeGestureAndroid, transformGestureAndroid, } from '../multitouch-helper.ts'; import { @@ -101,6 +102,36 @@ test('runAndroidMultiTouchHelperGesture encodes protocol payload for instrumenta assert.equal(capturedOptions?.timeoutMs, 45_000); }); +test('runAndroidMultiTouchHelperGesture encodes one-finger swipe payloads', async () => { + let capturedPayload: Record | undefined; + const result = await runAndroidMultiTouchHelperGesture({ + adb: async (args) => { + capturedPayload = JSON.parse(Buffer.from(args[6]!, 'base64').toString('utf8')); + return { + exitCode: 0, + stdout: [resultRecord({ ok: 'true', kind: 'swipe' }), 'INSTRUMENTATION_CODE: 0'].join( + '\n', + ), + stderr: '', + }; + }, + request: { kind: 'swipe', x1: 340, y1: 400, x2: 60, y2: 400, durationMs: 300 }, + packageName: manifest.packageName, + instrumentationRunner: manifest.instrumentationRunner, + }); + + assert.equal(result.kind, 'swipe'); + assert.deepEqual(capturedPayload, { + protocol: 'android-multitouch-helper-v1', + kind: 'swipe', + x1: 340, + y1: 400, + x2: 60, + y2: 400, + durationMs: 300, + }); +}); + test('parseAndroidMultiTouchHelperOutput distinguishes missing final results', () => { assert.throws(() => parseAndroidMultiTouchHelperOutput('INSTRUMENTATION_CODE: 0'), { code: 'ANDROID_MULTITOUCH_HELPER_NO_FINAL_RESULT', @@ -135,7 +166,7 @@ test('runAndroidMultiTouchHelperGesture preserves helper failure messages', asyn ); }); -test('pinchAndroid, rotateGestureAndroid, and transformGestureAndroid prefer provider-native touch injection', async () => { +test('swipeAndroid and multi-touch gestures prefer provider-native touch injection', async () => { const calls: unknown[] = []; await withAndroidAdbProvider( { @@ -149,6 +180,13 @@ test('pinchAndroid, rotateGestureAndroid, and transformGestureAndroid prefer pro }, { serial: ANDROID_EMULATOR.id }, async () => { + const swipe = await swipeGestureAndroid(ANDROID_EMULATOR, { + x1: 340, + y1: 400, + x2: 60, + y2: 400, + durationMs: 300, + }); const pinch = await pinchAndroid(ANDROID_EMULATOR, { scale: 2, x: 100, y: 200 }); const rotate = await rotateGestureAndroid(ANDROID_EMULATOR, { degrees: -215, @@ -164,6 +202,7 @@ test('pinchAndroid, rotateGestureAndroid, and transformGestureAndroid prefer pro degrees: 35, }); + assert.equal(swipe?.backend, 'provider-native-touch'); assert.equal(pinch.backend, 'provider-native-touch'); assert.equal(rotate.backend, 'provider-native-touch'); assert.equal(transform.backend, 'provider-native-touch'); @@ -171,6 +210,7 @@ test('pinchAndroid, rotateGestureAndroid, and transformGestureAndroid prefer pro ); assert.deepEqual(calls, [ + { kind: 'swipe', x1: 340, y1: 400, x2: 60, y2: 400, durationMs: 300 }, { kind: 'pinch', x: 100, y: 200, scale: 2, durationMs: undefined }, { kind: 'rotate', x: 100, y: 200, degrees: -215, durationMs: undefined }, { diff --git a/src/platforms/android/adb-executor.ts b/src/platforms/android/adb-executor.ts index 4f423a899..e07831ddc 100644 --- a/src/platforms/android/adb-executor.ts +++ b/src/platforms/android/adb-executor.ts @@ -120,6 +120,14 @@ export type AndroidTextInjectionRequest = { export type AndroidTextInjector = (request: AndroidTextInjectionRequest) => Promise; export type AndroidTouchGestureRequest = + | { + kind: 'swipe'; + x1: number; + y1: number; + x2: number; + y2: number; + durationMs?: number; + } | { kind: 'pinch'; x: number; diff --git a/src/platforms/android/multitouch-helper.ts b/src/platforms/android/multitouch-helper.ts index 69d3c6ac4..73f9af5c5 100644 --- a/src/platforms/android/multitouch-helper.ts +++ b/src/platforms/android/multitouch-helper.ts @@ -14,7 +14,7 @@ import { type AndroidAdbProvider, type AndroidTouchGestureRequest, } from './adb-executor.ts'; -import { getAndroidScreenSize } from './input-actions.ts'; +import { getAndroidScreenSize, swipeAndroid } from './input-actions.ts'; const ANDROID_MULTITOUCH_HELPER_NAME = 'android-multitouch-helper'; const ANDROID_MULTITOUCH_HELPER_PACKAGE = 'com.callstack.agentdevice.multitouchhelper'; @@ -48,6 +48,14 @@ type AndroidMultiTouchHelperArtifact = { }; type AndroidMultiTouchHelperGestureRequest = + | { + kind: 'swipe'; + x1: number; + y1: number; + x2: number; + y2: number; + durationMs: number; + } | { kind: 'pinch'; x: number; @@ -100,6 +108,41 @@ export type AndroidTransformGestureOptions = { durationMs?: number; }; +export type AndroidSwipeGestureOptions = { + x1: number; + y1: number; + x2: number; + y2: number; + durationMs?: number; +}; + +export async function swipeGestureAndroid( + device: DeviceInfo, + options: AndroidSwipeGestureOptions, +): Promise | void> { + const providerTouch = resolveAndroidTouchInjector(device); + if (providerTouch) { + return { + backend: 'provider-native-touch', + ...((await providerTouch({ kind: 'swipe', ...options })) ?? {}), + }; + } + + try { + return await runAndroidMultiTouchGesture(device, { kind: 'swipe', ...options }); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'android_swipe_helper_fallback', + data: { + error: normalizeError(error).message, + }, + }); + await swipeAndroid(device, options.x1, options.y1, options.x2, options.y2, options.durationMs); + return { backend: 'adb-input-swipe-fallback' }; + } +} + export async function pinchAndroid( device: DeviceInfo, options: AndroidPinchGestureOptions, @@ -234,6 +277,15 @@ function normalizeHelperGestureRequest( ): AndroidMultiTouchHelperGestureRequest { const durationMs = Math.round(resolveHelperGestureDurationMs(request)); switch (request.kind) { + case 'swipe': + return { + kind: 'swipe', + x1: Math.round(request.x1), + y1: Math.round(request.y1), + x2: Math.round(request.x2), + y2: Math.round(request.y2), + durationMs, + }; case 'pinch': return { kind: 'pinch', @@ -270,7 +322,7 @@ function resolveHelperGestureDurationMs(request: AndroidTouchGestureRequest): nu if (request.durationMs !== undefined) { return request.durationMs; } - if (request.kind === 'pinch') { + if (request.kind === 'swipe' || request.kind === 'pinch') { return ANDROID_MULTITOUCH_HELPER_DEFAULT_DURATION_MS; } const angleBasedDuration = From 28087922ba432cf48320584ed46e198a53f3c474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 4 Jun 2026 08:29:15 -0700 Subject: [PATCH 12/12] test: cover android swipe helper fallback --- .../__tests__/multitouch-helper.test.ts | 50 ++++++++++++++++++- src/platforms/android/multitouch-helper.ts | 2 +- src/utils/cli-help.ts | 2 +- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/platforms/android/__tests__/multitouch-helper.test.ts b/src/platforms/android/__tests__/multitouch-helper.test.ts index 17586f035..fc7d24a89 100644 --- a/src/platforms/android/__tests__/multitouch-helper.test.ts +++ b/src/platforms/android/__tests__/multitouch-helper.test.ts @@ -166,7 +166,7 @@ test('runAndroidMultiTouchHelperGesture preserves helper failure messages', asyn ); }); -test('swipeAndroid and multi-touch gestures prefer provider-native touch injection', async () => { +test('swipeGestureAndroid and multi-touch gestures prefer provider-native touch injection', async () => { const calls: unknown[] = []; await withAndroidAdbProvider( { @@ -226,6 +226,54 @@ test('swipeAndroid and multi-touch gestures prefer provider-native touch injecti ]); }); +test('swipeGestureAndroid falls back to adb input swipe when helper path is unavailable', async () => { + const adbCalls: string[][] = []; + const result = await withAndroidAdbProvider( + { + exec: async (args) => { + adbCalls.push(args); + if (args.includes('--show-versioncode')) { + return { + exitCode: 0, + stdout: `package:${manifest.packageName} versionCode:999999`, + stderr: '', + }; + } + if (args.includes('instrument')) { + return { + exitCode: 1, + stdout: [ + resultRecord({ + ok: 'false', + errorType: 'java.lang.IllegalStateException', + message: 'injectInputEvent returned false', + }), + 'INSTRUMENTATION_CODE: 1', + ].join('\n'), + stderr: '', + }; + } + if (args.join(' ') === 'shell input swipe 340 400 60 400 300') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => + await swipeGestureAndroid(ANDROID_EMULATOR, { + x1: 340, + y1: 400, + x2: 60, + y2: 400, + durationMs: 300, + }), + ); + + assert.deepEqual(result, { backend: 'adb-input-swipe-fallback' }); + assert.ok(adbCalls.some((args) => args.join(' ') === 'shell input swipe 340 400 60 400 300')); +}); + test('rotateGestureAndroid rejects zero velocity before provider dispatch', async () => { await withAndroidAdbProvider( { diff --git a/src/platforms/android/multitouch-helper.ts b/src/platforms/android/multitouch-helper.ts index 73f9af5c5..da45bdba6 100644 --- a/src/platforms/android/multitouch-helper.ts +++ b/src/platforms/android/multitouch-helper.ts @@ -473,7 +473,7 @@ async function resolveAndroidMultiTouchHelperArtifact(): Promise, @ref, @Label_Name, or @eN placeholders. Close means agent-device close. App-owned back means back; system back means back --system. - Taps are press or click. Gestures use swipe, longpress, or gesture . Use gesture swipe left|right for reliable in-page horizontal swipes, and gesture swipe right-edge for left-edge navigation/back gestures. Android pinch, rotate, and transform use provider-native touch injection when available, then the bundled multi-touch helper. iOS simulator transform uses private XCTest synthesis for a continuous two-finger pan/scale/rotation path; otherwise it reports UNSUPPORTED_OPERATION. + Taps are press or click. Gestures use swipe, longpress, or gesture . Use gesture swipe left|right for reliable in-page horizontal swipes, and gesture swipe right-edge for left-edge navigation/back gestures. Android swipe, pinch, rotate, and transform use provider-native touch injection when available, then the bundled touch helper. iOS simulator transform uses private XCTest synthesis for a continuous two-finger pan/scale/rotation path; otherwise it reports UNSUPPORTED_OPERATION. Bootstrap: agent-device devices --platform ios