From b434591c15d3e940311e4aeafcdc56660a12e588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 28 May 2026 20:53:40 +0200 Subject: [PATCH 01/13] fix: improve maestro tab target swipes --- .../__tests__/runtime-interactions.test.ts | 51 ++++++++++- .../maestro/__tests__/runtime-targets.test.ts | 49 ++++++++++ src/compat/maestro/runtime-interactions.ts | 21 +++-- src/compat/maestro/runtime-targets.ts | 90 ++++++++++++++++--- .../__tests__/session-replay-vars.test.ts | 2 +- 5 files changed, 193 insertions(+), 20 deletions(-) diff --git a/src/compat/maestro/__tests__/runtime-interactions.test.ts b/src/compat/maestro/__tests__/runtime-interactions.test.ts index c7212721e..9602e0734 100644 --- a/src/compat/maestro/__tests__/runtime-interactions.test.ts +++ b/src/compat/maestro/__tests__/runtime-interactions.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts'; import type { SnapshotState } from '../../../utils/snapshot.ts'; -import { invokeMaestroTapOn } from '../runtime-interactions.ts'; +import { invokeMaestroSwipeScreen, invokeMaestroTapOn } from '../runtime-interactions.ts'; test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', async () => { const selector = @@ -34,6 +34,31 @@ test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', expect(clicks).toEqual([['86', '89']]); }); +test('invokeMaestroSwipeScreen uses a conservative Android content-lane directional swipe', async () => { + const swipes: string[][] = []; + const response = await invokeMaestroSwipeScreen({ + baseReq: { + token: 'test', + session: 'pager', + flags: { platform: 'android' }, + }, + positionals: ['direction', 'left', '300'], + invoke: async (req: DaemonRequest): Promise => { + if (req.command === 'snapshot') { + return { ok: true, data: fullScreenSnapshot(1080, 2340) }; + } + if (req.command === 'swipe') { + swipes.push(req.positionals ?? []); + return { ok: true, data: {} }; + } + return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; + }, + }); + + expect(response.ok).toBe(true); + expect(swipes).toEqual([['756', '1521', '324', '1521', '300']]); +}); + function currentBreadcrumbSnapshot(): SnapshotState { return { createdAt: Date.now(), @@ -62,6 +87,30 @@ function currentBreadcrumbSnapshot(): SnapshotState { }; } +function fullScreenSnapshot(width: number, height: number): SnapshotState { + return { + createdAt: Date.now(), + nodes: [ + { + index: 0, + ref: 'e1', + type: 'Application', + label: 'Android Test App', + depth: 0, + rect: { x: 0, y: 0, width, height }, + }, + { + index: 1, + ref: 'e2', + type: 'Window', + depth: 1, + parentIndex: 0, + rect: { x: 0, y: 0, width, height }, + }, + ], + }; +} + function appNode(): SnapshotState['nodes'][number] { return { index: 0, diff --git a/src/compat/maestro/__tests__/runtime-targets.test.ts b/src/compat/maestro/__tests__/runtime-targets.test.ts index d8279f795..83a444cca 100644 --- a/src/compat/maestro/__tests__/runtime-targets.test.ts +++ b/src/compat/maestro/__tests__/runtime-targets.test.ts @@ -250,6 +250,55 @@ test('resolveMaestroNodeFromSnapshot keeps concrete child matches over tab-strip }); }); +test('resolveMaestroNodeFromSnapshot infers leading breadcrumb slot when selected child is omitted', () => { + const snapshot: SnapshotState = { + createdAt: Date.now(), + nodes: [ + { + index: 1, + ref: 'e1', + type: 'ScrollView', + label: 'Article by Gandalf', + rect: { x: 0, y: 58.33333333333333, width: 402, height: 58.33333333333333 }, + depth: 4, + }, + { + index: 2, + ref: 'e2', + type: 'Other', + label: 'Feed', + rect: { x: 170.3333282470703, y: 65.33333587646484, width: 54, height: 48 }, + depth: 5, + parentIndex: 1, + }, + { + index: 3, + ref: 'e3', + type: 'Other', + label: 'Albums', + rect: { x: 231.6666717529297, y: 65.33333587646484, width: 75, height: 48 }, + depth: 5, + parentIndex: 1, + }, + ], + }; + + const target = resolveMaestroNodeFromSnapshot( + snapshot, + 'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"', + {}, + 'ios', + { referenceWidth: 402, referenceHeight: 874 }, + { promoteTapTarget: true }, + ); + + expect(target).toMatchObject({ + ok: true, + node: expect.objectContaining({ index: 1 }), + rect: { x: 0, y: 58.33333333333333, width: 168, height: 58.33333333333333 }, + }); +}); + function makeReactNativeOverlaySnapshot(): SnapshotState { return { createdAt: Date.now(), diff --git a/src/compat/maestro/runtime-interactions.ts b/src/compat/maestro/runtime-interactions.ts index 0592cdf7f..a68e02130 100644 --- a/src/compat/maestro/runtime-interactions.ts +++ b/src/compat/maestro/runtime-interactions.ts @@ -270,12 +270,14 @@ function resolveDirectionalScreenSwipe( case 'down': return { ok: true, start: point(50, 20), end: point(50, 80), durationMs }; case 'left': { - const yPercent = androidHorizontalContentSwipeY(platform, 80, 50, 20, 50); - return { ok: true, start: point(80, yPercent), end: point(20, yPercent), durationMs }; + const [startX, endX] = androidHorizontalDirectionalSwipeX(platform, 80, 20); + const yPercent = androidHorizontalContentSwipeY(platform, startX, 50, endX, 50); + return { ok: true, start: point(startX, yPercent), end: point(endX, yPercent), durationMs }; } case 'right': { - const yPercent = androidHorizontalContentSwipeY(platform, 20, 50, 80, 50); - return { ok: true, start: point(20, yPercent), end: point(80, yPercent), durationMs }; + const [startX, endX] = androidHorizontalDirectionalSwipeX(platform, 20, 80); + const yPercent = androidHorizontalContentSwipeY(platform, startX, 50, endX, 50); + return { ok: true, start: point(startX, yPercent), end: point(endX, yPercent), durationMs }; } default: return { @@ -288,6 +290,15 @@ function resolveDirectionalScreenSwipe( } } +function androidHorizontalDirectionalSwipeX( + platform: string, + startX: number, + endX: number, +): [number, number] { + if (platform !== 'android') return [startX, endX]; + return startX < endX ? [30, 70] : [70, 30]; +} + function resolvePercentScreenSwipe( args: string[], frame: { referenceWidth: number; referenceHeight: number }, @@ -320,7 +331,7 @@ function androidHorizontalContentSwipeY( ): number { if (platform !== 'android') return y2; if (y1 !== y2 || y1 !== 50) return y2; - if (Math.abs(x2 - x1) < 50) return y2; + if (Math.abs(x2 - x1) < 30) return y2; // Maestro's Android driver treats 50% horizontal swipes as content swipes. // Raw `adb input swipe` at the physical screen midpoint can land above // horizontally paged content in React Native layouts, so use a lower content diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index 5eb51d6c2..05837d16c 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -476,24 +476,88 @@ function inferMaestroMissingTabSlotMatch( query: string, ): MaestroResolvedSnapshotMatch | null { if (!isMaestroTabStripContainerMatch(match, query)) return null; - const children: Array = []; - for (const node of nodes) { - if (node.parentIndex !== match.node.index || !node.rect) continue; - const candidate = node as SnapshotNode & { rect: Rect }; - if (isMaestroTabStripChildCandidate(candidate, match.rect, query)) { - children.push(candidate); - } - } - children.sort((left, right) => left.rect.x - right.rect.x); + const children = collectMaestroTabStripChildCandidates(nodes, match, query); if (children.length === 0) return null; const medianChildWidth = median(children.map((child) => child.rect.width)); - const gaps = resolveHorizontalGaps( + const allGaps = resolveHorizontalGaps( match.rect, children.map((child) => child.rect), - ).filter((gap) => isPlausibleMissingTabSlot(gap.width, medianChildWidth)); - if (gaps.length !== 1) return null; - const gap = gaps[0]; + ); + const gap = selectMaestroMissingSlotGap(match, query, allGaps, medianChildWidth); if (!gap) return null; + return matchWithRect(match, gap); +} + +function collectMaestroTabStripChildCandidates( + nodes: SnapshotState['nodes'], + match: MaestroResolvedSnapshotMatch, + query: string, +): Array { + return nodes + .filter((node): node is SnapshotNode & { rect: Rect } => { + return ( + node.parentIndex === match.node.index && + Boolean(node.rect) && + isMaestroTabStripChildCandidate(node as SnapshotNode & { rect: Rect }, match.rect, query) + ); + }) + .sort((left, right) => left.rect.x - right.rect.x); +} + +function selectMaestroMissingSlotGap( + match: MaestroResolvedSnapshotMatch, + query: string, + gaps: Array<{ x: number; width: number }>, + medianChildWidth: number, +): { x: number; width: number } | null { + const plausibleGaps = gaps.filter((gap) => isPlausibleMissingTabSlot(gap.width, medianChildWidth)); + const leadingTextSlot = inferMaestroLeadingTextSlotGap(match, query, gaps); + const hasPlausibleLeadingGap = plausibleGaps.some((gap) => isLeadingGap(match.rect, gap)); + if (leadingTextSlot && !hasPlausibleLeadingGap) return leadingTextSlot; + if (plausibleGaps.length === 1) return plausibleGaps[0] ?? null; + return leadingTextSlot; +} + +function inferMaestroLeadingTextSlotGap( + match: MaestroResolvedSnapshotMatch, + query: string, + gaps: Array<{ x: number; width: number }>, +): { x: number; width: number } | null { + const leadingGap = gaps.find((gap) => Math.abs(gap.x - match.rect.x) < 1); + const estimatedLabelWidth = Math.max(48, Math.min(220, query.length * 8 + 24)); + if (!isLeadingTextSlotCandidate(match, query, leadingGap, estimatedLabelWidth)) return null; + return { + x: match.rect.x, + width: Math.min(estimatedLabelWidth, leadingGap.width), + }; +} + +function isLeadingTextSlotCandidate( + match: MaestroResolvedSnapshotMatch, + query: string, + gap: { x: number; width: number } | undefined, + estimatedLabelWidth: number, +): gap is { x: number; width: number } { + if (!gap) return false; + return ( + normalizeType(match.node.type ?? '') === 'scrollview' && + maestroVisibleTextMatchRank(match.node, query) <= 1 && + match.rect.width >= 240 && + match.rect.height >= 32 && + match.rect.height <= 80 && + gap.width <= match.rect.width * 0.55 && + gap.width >= estimatedLabelWidth * 0.6 + ); +} + +function isLeadingGap(rect: Rect, gap: { x: number; width: number }): boolean { + return Math.abs(gap.x - rect.x) < 1; +} + +function matchWithRect( + match: MaestroResolvedSnapshotMatch, + gap: { x: number; width: number }, +): MaestroResolvedSnapshotMatch { return { ...match, rect: { diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index c9f121eba..03cae20c8 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -1473,7 +1473,7 @@ test('runReplayScriptFile uses Android content lane for Maestro horizontal scree calls.map((call) => [call.command, call.positionals]), [ ['snapshot', []], - ['swipe', ['320', '520', '80', '520', '300']], + ['swipe', ['280', '520', '120', '520', '300']], ['swipe', ['360', '520', '40', '520', '300']], ], ); From f1b1a74bbfb1313fa7fab60f1e34250b60cf1abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 28 May 2026 22:24:46 +0200 Subject: [PATCH 02/13] fix: stop replay suites on pending timeout cleanup --- .../session-test-infrastructure.test.ts | 13 +++++ .../__tests__/session-test-runtime.test.ts | 12 +++-- .../handlers/session-test-infrastructure.ts | 1 + src/daemon/handlers/session-test-runtime.ts | 47 ++++++++++++++++++- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts b/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts index ccedb58e3..e4257b99a 100644 --- a/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts +++ b/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts @@ -28,6 +28,19 @@ test('isReplayInfrastructureFailure keeps message fallback for legacy errors', ( assert.equal(isReplayInfrastructureFailure(response), true); }); +test('isReplayInfrastructureFailure accepts replay timeout cleanup races', () => { + const response: DaemonResponse = { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'TIMEOUT after 120000ms', + details: { reason: 'timeout_cleanup_pending', timeoutCleanupPending: true }, + }, + }; + + assert.equal(isReplayInfrastructureFailure(response), true); +}); + test('isReplayInfrastructureFailure rejects normal replay failures', () => { const response: DaemonResponse = { ok: false, diff --git a/src/daemon/handlers/__tests__/session-test-runtime.test.ts b/src/daemon/handlers/__tests__/session-test-runtime.test.ts index e9e72ea76..087fc87ee 100644 --- a/src/daemon/handlers/__tests__/session-test-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-test-runtime.test.ts @@ -33,6 +33,8 @@ test('runReplayTestAttempt keeps cancellation active until a timed-out replay se expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.message).toContain('TIMEOUT after 10ms'); + expect(result.error.details?.reason).toBe('timeout_cleanup_pending'); + expect(result.error.details?.timeoutCleanupPending).toBe(true); } expect(cleanupSession).toHaveBeenCalledWith('default:test:timeout'); expect(isRequestCanceled('req-timeout-open')).toBe(true); @@ -42,8 +44,10 @@ test('runReplayTestAttempt keeps cancellation active until a timed-out replay se error: { code: 'COMMAND_FAILED', message: 'request canceled' }, }); await replaySettled; - await Promise.resolve(); - await Promise.resolve(); - - expect(isRequestCanceled('req-timeout-open')).toBe(false); + await vi.waitFor(() => { + expect(isRequestCanceled('req-timeout-open')).toBe(false); + }); + await vi.waitFor(() => { + expect(cleanupSession).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/daemon/handlers/session-test-infrastructure.ts b/src/daemon/handlers/session-test-infrastructure.ts index 77d7ead55..85b515e10 100644 --- a/src/daemon/handlers/session-test-infrastructure.ts +++ b/src/daemon/handlers/session-test-infrastructure.ts @@ -32,6 +32,7 @@ function readReplayFailureError( function hasInfrastructureFailureReason(details: Record | undefined): boolean { const reason = typeof details?.reason === 'string' ? details.reason : ''; + if (reason === 'timeout_cleanup_pending') return true; return reason ? isInfrastructureBootFailureReason(reason) : false; } diff --git a/src/daemon/handlers/session-test-runtime.ts b/src/daemon/handlers/session-test-runtime.ts index 8b8d74677..74cc4622b 100644 --- a/src/daemon/handlers/session-test-runtime.ts +++ b/src/daemon/handlers/session-test-runtime.ts @@ -13,6 +13,7 @@ import type { ReplayTestRuntimeDependencies } from './session-test-types.ts'; const REPLAY_TIMEOUT_CLEANUP_GRACE_MS = 2_000; const REPLAY_TEST_TIMEOUT_HINT = 'Replay test timeouts are cooperative; the active command may take a short grace period to stop.'; +const REPLAY_TIMEOUT_CLEANUP_PENDING_REASON = 'timeout_cleanup_pending'; export async function runReplayTestAttempt( params: { @@ -40,6 +41,7 @@ export async function runReplayTestAttempt( const artifactPaths = new Set(); let timeoutHandle: ReturnType | undefined; let timedOut = false; + let response: DaemonResponse | undefined; const replayPromise = runReplay({ filePath, sessionName, @@ -61,7 +63,7 @@ export async function runReplayTestAttempt( }); try { - const response = + response = typeof timeoutMs === 'number' ? await Promise.race([ replayPromise, @@ -80,6 +82,7 @@ export async function runReplayTestAttempt( if (timedOut) { const settled = await waitForReplayAfterTimeout(replayPromise); if (!settled) { + markReplayTimeoutCleanupPending(response); emitDiagnostic({ level: 'warn', phase: 'test_timeout_cleanup_race', @@ -89,6 +92,12 @@ export async function runReplayTestAttempt( graceMs: REPLAY_TIMEOUT_CLEANUP_GRACE_MS, }, }); + void cleanupSessionAfterLateReplay({ + replayPromise, + cleanupSession, + sessionName, + requestId, + }); } } try { @@ -114,6 +123,42 @@ async function waitForReplayAfterTimeout(replayPromise: Promise) ]); } +async function cleanupSessionAfterLateReplay(params: { + replayPromise: Promise; + cleanupSession: ReplayTestRuntimeDependencies['cleanupSession']; + sessionName: string; + requestId: string; +}): Promise { + const { replayPromise, cleanupSession, sessionName, requestId } = params; + try { + await replayPromise; + } finally { + try { + await cleanupSession(sessionName); + } catch (error) { + const appErr = normalizeError(error); + emitDiagnostic({ + level: 'warn', + phase: 'test_late_cleanup_failed', + data: { + session: sessionName, + requestId, + error: appErr.message, + }, + }); + } + } +} + +function markReplayTimeoutCleanupPending(response: DaemonResponse | undefined): void { + if (!response || response.ok) return; + response.error.details = { + ...(response.error.details ?? {}), + reason: REPLAY_TIMEOUT_CLEANUP_PENDING_REASON, + timeoutCleanupPending: true, + }; +} + function createReplayTestTimeoutResponse( timeoutMs: number, artifactPaths: string[] = [], From f74ad062a3424f84931b447b62e73bb0cb353f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 28 May 2026 23:13:53 +0200 Subject: [PATCH 03/13] fix: tighten maestro compat support plumbing --- scripts/run-test-app-maestro-suite.mjs | 11 ++- .../maestro/__tests__/support-matrix.test.ts | 28 +++++++ src/compat/maestro/runtime-flow.ts | 65 ++++++---------- src/compat/maestro/runtime-geometry.ts | 18 +---- src/compat/maestro/runtime-support.ts | 16 ---- src/compat/maestro/support-matrix.ts | 42 +++++++++++ src/compat/maestro/support.ts | 4 +- src/replay/control-flow-runtime.ts | 74 +++++++++++++++++++ src/utils/__tests__/rect-center.test.ts | 13 ++++ src/utils/cli-flags.ts | 8 +- src/utils/rect-center.ts | 18 ++++- website/docs/docs/replay-e2e.md | 3 +- 12 files changed, 213 insertions(+), 87 deletions(-) create mode 100644 src/compat/maestro/__tests__/support-matrix.test.ts create mode 100644 src/compat/maestro/support-matrix.ts create mode 100644 src/replay/control-flow-runtime.ts create mode 100644 src/utils/__tests__/rect-center.test.ts diff --git a/scripts/run-test-app-maestro-suite.mjs b/scripts/run-test-app-maestro-suite.mjs index 3d9ffcdfb..f0268e00e 100644 --- a/scripts/run-test-app-maestro-suite.mjs +++ b/scripts/run-test-app-maestro-suite.mjs @@ -72,9 +72,14 @@ if (options.openTarget) { runAgentDevice(['wait', 'Agent Device Tester', '30000', '--platform', options.platform, ...options.passthrough]); } -for (const flow of flows) { - runAgentDevice(['replay', flow, '--maestro', '--platform', options.platform, ...options.passthrough]); -} +runAgentDevice([ + 'test', + options.flowDir, + '--maestro', + '--platform', + options.platform, + ...options.passthrough, +]); if (options.close) { runAgentDevice(['close']); diff --git a/src/compat/maestro/__tests__/support-matrix.test.ts b/src/compat/maestro/__tests__/support-matrix.test.ts new file mode 100644 index 000000000..d11654b9d --- /dev/null +++ b/src/compat/maestro/__tests__/support-matrix.test.ts @@ -0,0 +1,28 @@ +import fs from 'node:fs'; +import { expect, test } from 'vitest'; +import { getFlagDefinitions } from '../../../utils/cli-flags.ts'; +import { + MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, + MAESTRO_COMPAT_TRACKER_URL, + MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES, + formatMaestroCapabilityList, +} from '../support-matrix.ts'; + +test('Maestro CLI help uses the shared compatibility support matrix', () => { + const flag = getFlagDefinitions().find((definition) => definition.key === 'replayMaestro'); + expect(flag?.usageDescription).toContain( + `Supported subset: ${formatMaestroCapabilityList(MAESTRO_COMPAT_SUPPORTED_CAPABILITIES)}.`, + ); + expect(flag?.usageDescription).toContain(MAESTRO_COMPAT_TRACKER_URL); +}); + +test('Maestro replay docs stay in sync with the compatibility support matrix', () => { + const docs = fs.readFileSync('website/docs/docs/replay-e2e.md', 'utf8'); + const plainDocs = docs.replace(/`/g, ''); + for (const capability of MAESTRO_COMPAT_SUPPORTED_CAPABILITIES) { + expect(plainDocs).toContain(capability); + } + for (const capability of MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES) { + expect(plainDocs).toContain(capability); + } +}); diff --git a/src/compat/maestro/runtime-flow.ts b/src/compat/maestro/runtime-flow.ts index 4feb4f962..256b096d9 100644 --- a/src/compat/maestro/runtime-flow.ts +++ b/src/compat/maestro/runtime-flow.ts @@ -1,8 +1,12 @@ import { type CommandFlags } from '../../core/dispatch.ts'; -import type { DaemonRequest, DaemonResponse, SessionAction } from '../../daemon/types.ts'; +import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts'; import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts'; import { - batchStepToSessionAction, + batchStepsToSessionActions, + invokeReplayActionBlock, + invokeReplayRetryBlock, +} from '../../replay/control-flow-runtime.ts'; +import { captureMaestroRawSnapshot, errorResponse, readSnapshotState, @@ -53,16 +57,13 @@ export async function invokeMaestroRetry(params: { return errorResponse('INVALID_ARGS', 'retry.maxRetries must be a non-negative integer.'); } - const steps = (params.batchSteps ?? []).map(batchStepToSessionAction); - let lastResponse: DaemonResponse | undefined; - for (let attempt = 0; attempt <= maxRetries; attempt += 1) { - const response = await invokeMaestroRetryAttempt(params, steps, attempt); - if (response.ok) { - return { ok: true, data: { attempts: attempt + 1, retried: attempt > 0 } }; - } - lastResponse = response; - } - return lastResponse ?? errorResponse('COMMAND_FAILED', 'retry commands failed.'); + return await invokeReplayRetryBlock({ + actions: batchStepsToSessionActions(params.batchSteps), + maxRetries, + line: params.line, + step: params.step, + invokeReplayAction: params.invokeReplayAction, + }); } function readMaestroRunFlowWhenCondition(positionals: string[]): MaestroRunFlowWhenCondition { @@ -118,40 +119,16 @@ async function invokeMaestroRunFlowWhenSteps( }, condition: Extract, ): Promise { - const steps = (params.batchSteps ?? []).map(batchStepToSessionAction); - for (const [index, action] of steps.entries()) { - // Preserve stable parent-step ordering for nested runtime commands while - // keeping the substep distinguishable in traces. - const response = await params.invokeReplayAction({ - action, - line: params.line, - step: params.step + index / 1000, - }); - if (!response.ok) return response; - } + const response = await invokeReplayActionBlock({ + actions: batchStepsToSessionActions(params.batchSteps), + line: params.line, + step: params.step, + invokeReplayAction: params.invokeReplayAction, + }); + if (!response.ok) return response; return { ok: true, - data: { ran: steps.length, condition: condition.mode, selector: condition.selector }, + data: { ran: response.data?.ran, condition: condition.mode, selector: condition.selector }, }; } - -async function invokeMaestroRetryAttempt( - params: { - line: number; - step: number; - invokeReplayAction: MaestroReplayInvoker; - }, - steps: SessionAction[], - attempt: number, -): Promise { - for (const [index, action] of steps.entries()) { - const response = await params.invokeReplayAction({ - action, - line: params.line, - step: params.step + attempt + index / 1000, - }); - if (!response.ok) return response; - } - return { ok: true, data: { ran: steps.length } }; -} diff --git a/src/compat/maestro/runtime-geometry.ts b/src/compat/maestro/runtime-geometry.ts index e4cb6d77b..fb73424c1 100644 --- a/src/compat/maestro/runtime-geometry.ts +++ b/src/compat/maestro/runtime-geometry.ts @@ -1,4 +1,5 @@ import type { Rect, SnapshotNode } from '../../utils/snapshot.ts'; +import { interiorCoordinate, pointInsideRect } from '../../utils/rect-center.ts'; import { normalizeType } from '../../utils/snapshot-processing.ts'; import type { MaestroSnapshotTarget } from './runtime-targets.ts'; @@ -96,13 +97,6 @@ function clampCoordinate(value: number, min: number, max: number): number { return Math.round(Math.min(max, Math.max(min, value))); } -function pointInsideRect(rect: Rect): { x: number; y: number } { - return { - x: interiorCoordinate(rect.x, rect.width), - y: interiorCoordinate(rect.y, rect.height), - }; -} - function shouldBiasMaestroVisibleTextTap( node: SnapshotNode, isVisibleTextSelector: boolean, @@ -117,13 +111,3 @@ function shouldBiasMaestroVisibleTextTap( if (rect.height < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minHeight) return false; return type === 'cell' || type === 'other' || scrollableTextContainer; } - -function interiorCoordinate(origin: number, size: number): number { - // Maestro flows often expose hidden E2E controls as 1x1 views at the screen - // edge. Preserve zero-origin taps for those controls instead of nudging them - // outside their tiny rect by applying normal center/bounds clamping. - if (size <= 1) return Math.floor(origin); - const min = Math.ceil(origin); - const max = Math.floor(origin + size - 1); - return clampCoordinate(origin + size / 2, min, max); -} diff --git a/src/compat/maestro/runtime-support.ts b/src/compat/maestro/runtime-support.ts index fc7d6aa90..36b1cbafb 100644 --- a/src/compat/maestro/runtime-support.ts +++ b/src/compat/maestro/runtime-support.ts @@ -1,4 +1,3 @@ -import type { CommandFlags } from '../../core/dispatch.ts'; import { getSnapshotReferenceFrame, type TouchReferenceFrame, @@ -74,18 +73,3 @@ function rememberMaestroReferenceFrame(scope: ReplayVarScope, data: unknown): vo const frame = getSnapshotReferenceFrame(snapshot); if (frame) maestroReferenceFrameCache.set(scope, frame); } - -export function batchStepToSessionAction( - step: NonNullable[number], -): SessionAction { - const action: SessionAction = { - ts: Date.now(), - command: step.command, - positionals: step.positionals ?? [], - flags: step.flags ?? {}, - }; - if (step.runtime && typeof step.runtime === 'object') { - action.runtime = step.runtime as SessionAction['runtime']; - } - return action; -} diff --git a/src/compat/maestro/support-matrix.ts b/src/compat/maestro/support-matrix.ts new file mode 100644 index 000000000..73b579f46 --- /dev/null +++ b/src/compat/maestro/support-matrix.ts @@ -0,0 +1,42 @@ +export const MAESTRO_COMPAT_SUPPORTED_CAPABILITIES = [ + 'app launch with Apple-platform launch arguments and iOS simulator clearState', + 'runFlow file/inline with when.platform, when.visible, when.notVisible, and limited when.true boolean/platform expressions', + 'onFlowStart and onFlowComplete hooks', + 'deterministic repeat.times', + 'tapOn including optional, index, childOf, label, and absolute/percentage point taps', + 'doubleTapOn and longPressOn', + 'inputText, focused-field eraseText, and pasteText', + 'openLink', + 'visibility assertions and extendedWaitUntil', + 'scroll and scrollUntilVisible', + 'absolute/percentage swipe and swipe.label', + 'screenshots', + 'keyboard dismiss', + 'basic pressKey, back, animation waits, and stopApp', + 'ordered trusted runScript file/env scripts with http.post, json, and output variables', +] as const; + +export const MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES = [ + 'repeat.while', + 'full expression predicates beyond boolean literals and maestro.platform comparisons', + 'evalScript', + 'device utility commands', + 'Android app launch arguments', + 'Android app state reset', +] as const; + +export const MAESTRO_COMPAT_TRACKER_URL = + 'https://github.com/callstackincubator/agent-device/issues/558'; + +export const MAESTRO_NEW_ISSUE_URL = + 'https://github.com/callstackincubator/agent-device/issues/new'; + +export function formatMaestroSupportedSubsetForCli(): string { + return `Supported subset: ${formatMaestroCapabilityList(MAESTRO_COMPAT_SUPPORTED_CAPABILITIES)}.`; +} + +export function formatMaestroCapabilityList(capabilities: readonly string[]): string { + return capabilities.length > 1 + ? `${capabilities.slice(0, -1).join(', ')}, and ${capabilities.at(-1)}` + : (capabilities[0] ?? ''); +} diff --git a/src/compat/maestro/support.ts b/src/compat/maestro/support.ts index 0d26a98e3..1dc5a56f7 100644 --- a/src/compat/maestro/support.ts +++ b/src/compat/maestro/support.ts @@ -1,10 +1,8 @@ import type { SessionAction } from '../../daemon/types.ts'; import { AppError } from '../../utils/errors.ts'; +import { MAESTRO_COMPAT_TRACKER_URL, MAESTRO_NEW_ISSUE_URL } from './support-matrix.ts'; import type { MaestroCommand, MaestroFlowConfig, MaestroParseContext } from './types.ts'; -const MAESTRO_COMPAT_TRACKER_URL = 'https://github.com/callstackincubator/agent-device/issues/558'; -const MAESTRO_NEW_ISSUE_URL = 'https://github.com/callstackincubator/agent-device/issues/new'; - export function action( command: string, positionals: string[] = [], diff --git a/src/replay/control-flow-runtime.ts b/src/replay/control-flow-runtime.ts new file mode 100644 index 000000000..d7daa1125 --- /dev/null +++ b/src/replay/control-flow-runtime.ts @@ -0,0 +1,74 @@ +import type { CommandFlags } from '../core/dispatch.ts'; +import type { DaemonResponse, SessionAction } from '../daemon/types.ts'; + +export type ReplayActionBlockInvoker = (params: { + action: SessionAction; + line: number; + step: number; +}) => Promise; + +function batchStepToSessionAction( + step: NonNullable[number], +): SessionAction { + const action: SessionAction = { + ts: Date.now(), + command: step.command, + positionals: step.positionals ?? [], + flags: step.flags ?? {}, + }; + if (step.runtime && typeof step.runtime === 'object') { + action.runtime = step.runtime as SessionAction['runtime']; + } + return action; +} + +export function batchStepsToSessionActions( + batchSteps: CommandFlags['batchSteps'] | undefined, +): SessionAction[] { + return (batchSteps ?? []).map(batchStepToSessionAction); +} + +export async function invokeReplayActionBlock(params: { + actions: SessionAction[]; + line: number; + step: number; + invokeReplayAction: ReplayActionBlockInvoker; +}): Promise { + for (const [index, action] of params.actions.entries()) { + const response = await params.invokeReplayAction({ + action, + line: params.line, + step: params.step + index / 1000, + }); + if (!response.ok) return response; + } + return { ok: true, data: { ran: params.actions.length } }; +} + +export async function invokeReplayRetryBlock(params: { + actions: SessionAction[]; + maxRetries: number; + line: number; + step: number; + invokeReplayAction: ReplayActionBlockInvoker; +}): Promise { + let lastResponse: DaemonResponse | undefined; + for (let attempt = 0; attempt <= params.maxRetries; attempt += 1) { + const response = await invokeReplayActionBlock({ + actions: params.actions, + line: params.line, + step: params.step + attempt, + invokeReplayAction: params.invokeReplayAction, + }); + if (response.ok) { + return { ok: true, data: { attempts: attempt + 1, retried: attempt > 0 } }; + } + lastResponse = response; + } + return ( + lastResponse ?? { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'retry commands failed.' }, + } + ); +} diff --git a/src/utils/__tests__/rect-center.test.ts b/src/utils/__tests__/rect-center.test.ts new file mode 100644 index 000000000..be5004466 --- /dev/null +++ b/src/utils/__tests__/rect-center.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from 'vitest'; +import { interiorCoordinate, pointInsideRect } from '../rect-center.ts'; + +test('interiorCoordinate preserves one-pixel edge controls', () => { + expect(interiorCoordinate(0, 1)).toBe(0); +}); + +test('pointInsideRect clamps center point inside the rect bounds', () => { + expect(pointInsideRect({ x: 0.2, y: 10.2, width: 10, height: 5 })).toEqual({ + x: 5, + y: 13, + }); +}); diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 0238db260..8722515e4 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -5,6 +5,10 @@ import { SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS, type ScreenshotRequestFlags, } from '../commands/capture-screenshot-options.ts'; +import { + MAESTRO_COMPAT_TRACKER_URL, + formatMaestroSupportedSubsetForCli, +} from '../compat/maestro/support-matrix.ts'; export type CliFlags = RemoteConfigMetroOptions & ScreenshotRequestFlags & { @@ -760,8 +764,8 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ type: 'boolean', usageLabel: '--maestro', usageDescription: - 'Replay: treat input as a Maestro YAML compatibility flow. Supported subset: launchApp without state-reset side effects, runFlow file/inline with when.platform, onFlowStart/onFlowComplete, deterministic repeat.times, ordered trusted runScript, tapOn, doubleTapOn, longPressOn, inputText, pasteText, openLink, assertVisible, assertNotVisible, assertTrue literal true/false, extendedWaitUntil, scroll, absolute/percentage swipe, takeScreenshot, hideKeyboard, pressKey back/enter/home, back, waitForAnimationToEnd, stopApp/killApp, setAirplaneMode, setLocation, setOrientation, supported setPermissions targets, and startRecording/stopRecording. ' + - 'Unsupported syntax fails loudly with a link to https://github.com/callstackincubator/agent-device/issues/558', + `Replay: treat input as a Maestro YAML compatibility flow. ${formatMaestroSupportedSubsetForCli()} ` + + `Unsupported syntax fails loudly with a link to ${MAESTRO_COMPAT_TRACKER_URL}`, }, { key: 'replayEnv', diff --git a/src/utils/rect-center.ts b/src/utils/rect-center.ts index 93a5db3a2..b7a629dff 100644 --- a/src/utils/rect-center.ts +++ b/src/utils/rect-center.ts @@ -1,4 +1,4 @@ -import { centerOfRect, type Rect } from './snapshot.ts'; +import { centerOfRect, type Point, type Rect } from './snapshot.ts'; export function resolveRectCenter(rect: Rect | undefined): { x: number; y: number } | null { const normalized = normalizeRect(rect); @@ -25,3 +25,19 @@ export function normalizeRect(rect: Rect | undefined): Rect | null { if (width < 0 || height < 0) return null; return { x, y, width, height }; } + +export function pointInsideRect(rect: Rect): Point { + return { + x: interiorCoordinate(rect.x, rect.width), + y: interiorCoordinate(rect.y, rect.height), + }; +} + +export function interiorCoordinate(origin: number, size: number): number { + // Preserve one-pixel edge controls instead of nudging coordinates outside + // tiny rects through normal center/bounds clamping. + if (size <= 1) return Math.floor(origin); + const min = Math.ceil(origin); + const max = Math.floor(origin + size - 1); + return Math.round(Math.min(max, Math.max(min, origin + size / 2))); +} diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 836de2058..de74bacb2 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -54,6 +54,7 @@ Agent Device can run a supported subset of Maestro YAML through the replay runti ```bash agent-device replay ./flow.yaml --maestro --platform ios --session e2e-run +agent-device test ./maestro-flows --maestro --platform android --artifacts-dir ./tmp/maestro-artifacts ``` Maestro compatibility translates supported YAML commands into Agent Device replay actions. It is intended for common mobile flows, not full Maestro parity. Unsupported Maestro syntax fails loudly with the command or field name and a line number when available. If a missing command matters for your flows, use the compatibility tracker to check current support and share demand: @@ -61,7 +62,7 @@ Maestro compatibility translates supported YAML commands into Agent Device repla - Supported and unsupported capabilities: https://github.com/callstackincubator/agent-device/issues/558 - New focused compatibility request: https://github.com/callstackincubator/agent-device/issues/new -Currently supported areas include app launch with Apple-platform launch arguments and iOS simulator `clearState`, file and inline `runFlow` with `when.platform`, `when.visible`, `when.notVisible`, and limited `when.true` boolean/platform expressions, `onFlowStart` / `onFlowComplete`, deterministic `repeat.times`, `tapOn` including `optional`, `index`, `childOf`, `label`, and absolute/percentage point taps, `doubleTapOn`, `longPressOn`, `inputText`, focused-field `eraseText`, `pasteText`, `openLink`, visibility assertions, `extendedWaitUntil`, `scroll`, `scrollUntilVisible`, absolute/percentage `swipe`, `swipe.label`, screenshots, keyboard dismiss, basic `pressKey`, `back`, animation waits, and `stopApp`. `runScript` is supported only as an ordered Maestro compatibility step for trusted file/env scripts that use `http.post`, `json`, and `output` variables; it can make network requests, and is not a native `.ad` command or security sandbox. Script execution uses Node `vm` only for compatibility isolation, not for security; the script timeout bounds synchronous execution, while `http.post` requests are bounded by the helper process timeout. Output keys cannot contain `.` because exported variables are addressed as `output.`. +Currently supported areas include app launch with Apple-platform launch arguments and iOS simulator `clearState`, `runFlow` file/inline with `when.platform`, `when.visible`, `when.notVisible`, and limited `when.true` boolean/platform expressions, `onFlowStart` and `onFlowComplete` hooks, deterministic `repeat.times`, `tapOn` including `optional`, `index`, `childOf`, `label`, and absolute/percentage point taps, `doubleTapOn` and `longPressOn`, `inputText`, focused-field `eraseText`, and `pasteText`, `openLink`, visibility assertions and `extendedWaitUntil`, `scroll` and `scrollUntilVisible`, absolute/percentage `swipe` and `swipe.label`, screenshots, keyboard dismiss, basic `pressKey`, `back`, animation waits, and `stopApp`, and ordered trusted `runScript` file/env scripts with `http.post`, `json`, and `output` variables. `runScript` is supported only as an ordered Maestro compatibility step for trusted file/env scripts; it can make network requests, and is not a native `.ad` command or security sandbox. Script execution uses Node `vm` only for compatibility isolation, not for security; the script timeout bounds synchronous execution, while `http.post` requests are bounded by the helper process timeout. Output keys cannot contain `.` because exported variables are addressed as `output.`. Maestro `env` values use the same replay precedence as `.ad` files: flow `env` is the default, shell `AD_VAR_*` values override it, and CLI `-e KEY=VALUE` wins over both. From 73cbf8a0d169f3dfd855e06943176a779951738b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 08:30:26 +0200 Subject: [PATCH 04/13] fix: resolve ios simulator url open targets --- .../maestro/__tests__/runtime-targets.test.ts | 36 +++++ src/compat/maestro/runtime-targets.ts | 3 + src/daemon/handlers/__tests__/session.test.ts | 106 ++++++++++++++- src/daemon/handlers/session-open-target.ts | 23 +++- src/daemon/handlers/session-open.ts | 5 - src/platforms/ios/__tests__/index.test.ts | 71 ++++++++++ src/platforms/ios/apps.ts | 125 +++++++++++++++++- 7 files changed, 353 insertions(+), 16 deletions(-) diff --git a/src/compat/maestro/__tests__/runtime-targets.test.ts b/src/compat/maestro/__tests__/runtime-targets.test.ts index 83a444cca..578524e16 100644 --- a/src/compat/maestro/__tests__/runtime-targets.test.ts +++ b/src/compat/maestro/__tests__/runtime-targets.test.ts @@ -59,6 +59,42 @@ test('resolveMaestroNodeFromSnapshot blocks taps on app content behind React Nat }); }); +test('resolveVisibleMaestroNodeFromSnapshot does not block content behind collapsed React Native warnings', () => { + const snapshot: SnapshotState = { + createdAt: Date.now(), + nodes: [ + { + index: 1, + ref: 'e1', + type: 'android.widget.TextView', + label: 'Morning Favorites', + rect: { x: 24, y: 420, width: 320, height: 54 }, + depth: 8, + }, + { + index: 2, + ref: 'e2', + type: 'android.view.ViewGroup', + label: 'Open debugger to view warnings', + rect: { x: 0, y: 2190, width: 1080, height: 96 }, + depth: 6, + }, + ], + }; + + const appContent = resolveVisibleMaestroNodeFromSnapshot( + snapshot, + 'label="Morning Favorites" || text="Morning Favorites" || id="Morning Favorites"', + 'android', + { referenceWidth: 1080, referenceHeight: 2340 }, + ); + + expect(appContent).toMatchObject({ + ok: true, + node: expect.objectContaining({ label: 'Morning Favorites' }), + }); +}); + test('resolveMaestroNodeFromSnapshot prefers foreground duplicate matches', () => { const snapshot: SnapshotState = { createdAt: Date.now(), diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index 05837d16c..21bce36cb 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -184,6 +184,9 @@ function filterReactNativeOverlayBlockedMatches( if (!overlay.detected) { return { matches, blockedByReactNativeOverlay: false }; } + if (!overlay.redBox) { + return { matches, blockedByReactNativeOverlay: false }; + } const overlayNodeIndexes = new Set( [...overlay.dismissNodes, ...overlay.minimizeNodes, ...overlay.collapsedNodes].map( (node) => node.index, diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 61d5470ac..ac4d5a5a4 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -64,6 +64,7 @@ vi.mock('../../../platforms/ios/apps.ts', async (importOriginal) => { ...actual, listIosApps: vi.fn(async () => []), resolveIosApp: vi.fn(async () => undefined), + resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), }; }); vi.mock('../../app-log.ts', async (importOriginal) => { @@ -108,7 +109,7 @@ import { ensureAndroidEmulatorBooted, } from '../../../platforms/android/devices.ts'; import { listAppleDevices } from '../../../platforms/ios/devices.ts'; -import { resolveIosApp } from '../../../platforms/ios/apps.ts'; +import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId } from '../../../platforms/ios/apps.ts'; import { startAppLog, stopAppLog } from '../../app-log.ts'; import { defaultInstallOps, defaultReinstallOps } from '../session-deploy.ts'; import { clearRequestCanceled, markRequestCanceled } from '../../request-cancel.ts'; @@ -129,6 +130,7 @@ const mockRunCmd = vi.mocked(runCmd); const mockListAndroidDevices = vi.mocked(listAndroidDevices); const mockListAppleDevices = vi.mocked(listAppleDevices); const mockResolveIosApp = vi.mocked(resolveIosApp); +const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked(resolveIosSimulatorDeepLinkBundleId); const mockEnsureAndroidEmulatorBooted = vi.mocked(ensureAndroidEmulatorBooted); const mockStartAppLog = vi.mocked(startAppLog); const mockStopAppLog = vi.mocked(stopAppLog); @@ -177,6 +179,8 @@ beforeEach(() => { } return app.includes('.') ? app : `com.example.${normalizedApp}`; }); + mockResolveIosSimulatorDeepLinkBundleId.mockReset(); + mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue(undefined); mockEnsureAndroidEmulatorBooted.mockReset(); mockStartAppLog.mockReset(); mockStopAppLog.mockReset(); @@ -1865,6 +1869,102 @@ test('open URL on existing iOS device session preserves app bundle id context', expect(dispatchedContext?.appBundleId).toBe('com.example.app'); }); +test('open custom URL on existing iOS simulator session preserves app bundle id context', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.app', + appName: 'Example App', + }); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['myapp://item/42'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.example.app'); + expect(updated?.appName).toBe('myapp://item/42'); + expect(dispatchedContext?.appBundleId).toBe('com.example.app'); +}); + +test('open custom URL on fresh iOS simulator session infers app bundle id from URL scheme', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-url-session'; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue('org.reactnavigation.playground'); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['rne://navigator-layout'], + flags: { platform: 'ios', udid: 'sim-1' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockResolveIosSimulatorDeepLinkBundleId).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sim-1', kind: 'simulator' }), + 'rne://navigator-layout', + ); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('org.reactnavigation.playground'); + expect(updated?.appName).toBe('rne://navigator-layout'); + expect(dispatchedContext?.appBundleId).toBe('org.reactnavigation.playground'); + expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); + expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled(); +}); + test('open iOS app session prewarms runner session when app bundle id is known', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; @@ -1904,7 +2004,7 @@ test('open iOS app session prewarms runner session when app bundle id is known', expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled(); }); -test('open iOS URL without app bundle id keeps xctestrun-only prewarm', async () => { +test('open iOS URL without app bundle id skips runner prewarm', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; sessionStore.set( @@ -1935,7 +2035,7 @@ test('open iOS URL without app bundle id keeps xctestrun-only prewarm', async () expect(response).toBeTruthy(); expect(response?.ok).toBe(true); expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled(); - expect(mockPrewarmIosRunnerXctestrun).toHaveBeenCalledTimes(1); + expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled(); }); test('open web URL on iOS device session without active app falls back to Safari', async () => { diff --git a/src/daemon/handlers/session-open-target.ts b/src/daemon/handlers/session-open-target.ts index d7f14e214..93100db73 100644 --- a/src/daemon/handlers/session-open-target.ts +++ b/src/daemon/handlers/session-open-target.ts @@ -1,4 +1,8 @@ -import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts'; +import { + isDeepLinkTarget, + isWebUrl, + resolveIosDeviceDeepLinkBundleId, +} from '../../core/open-target.ts'; import type { DeviceInfo } from '../../utils/device.ts'; async function resolveIosBundleIdForOpen( @@ -12,11 +16,28 @@ async function resolveIosBundleIdForOpen( if (device.kind === 'device') { return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget); } + if (!isWebUrl(openTarget)) { + return ( + currentAppBundleId ?? (await tryResolveIosSimulatorDeepLinkBundleId(device, openTarget)) + ); + } return undefined; } return await tryResolveIosAppBundleId(device, openTarget); } +async function tryResolveIosSimulatorDeepLinkBundleId( + device: DeviceInfo, + openTarget: string, +): Promise { + try { + const { resolveIosSimulatorDeepLinkBundleId } = await import('../../platforms/ios/apps.ts'); + return await resolveIosSimulatorDeepLinkBundleId(device, openTarget); + } catch { + return undefined; + } +} + async function tryResolveIosAppBundleId( device: DeviceInfo, openTarget: string, diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index e344edc57..be5b084d8 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -5,7 +5,6 @@ import { contextFromFlags } from '../context.ts'; import { createRequestCanceledError, isRequestCanceled } from '../request-cancel.ts'; import { prewarmIosRunnerSession, - prewarmIosRunnerXctestrun, stopIosRunnerSession, } from '../../platforms/ios/runner-client.ts'; import { applyRuntimeHintsToApp } from '../runtime-hints.ts'; @@ -183,10 +182,6 @@ async function completeOpenCommand(params: { timing.runnerPrewarmKind = 'session'; timing.runnerPrewarmScheduled = true; runnerPrewarm = prewarmIosRunnerSession(device, runnerPrewarmOptions); - } else if (shouldPrewarmIosRunner) { - timing.runnerPrewarmKind = 'xctestrun'; - timing.runnerPrewarmScheduled = true; - runnerPrewarm = prewarmIosRunnerXctestrun(device, runnerPrewarmOptions); } const openStartedAtMs = Date.now(); await dispatchCommand(device, 'open', openPositionals, req.flags?.out, { diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index f9ea389e4..71337d4b7 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -52,6 +52,7 @@ import { readIosClipboardText, reinstallIosApp, resolveIosApp, + resolveIosSimulatorDeepLinkBundleId, screenshotIos, setIosSetting, shouldFallbackToRunnerForIosScreenshot, @@ -1640,6 +1641,76 @@ test('resolveIosApp caches display-name bundle matches but bypasses exact bundle } }); +test('resolveIosSimulatorDeepLinkBundleId maps custom URL scheme to installed user app', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-scheme-resolve-')); + const xcrunPath = path.join(tmpDir, 'xcrun'); + const plutilPath = path.join(tmpDir, 'plutil'); + const appPath = path.join(tmpDir, 'ReactNavigationExample.app'); + const runnerPath = path.join(tmpDir, 'AgentDeviceRunner.app'); + await fs.writeFile( + xcrunPath, + [ + '#!/bin/sh', + 'if [ "$1" = "simctl" ] && [ "$2" = "listapps" ]; then', + " cat <<'JSON'", + JSON.stringify({ + 'com.callstack.agentdevice.runner': { + ApplicationType: 'User', + CFBundleDisplayName: 'AgentDeviceRunner', + Path: runnerPath, + }, + 'org.reactnavigation.playground': { + ApplicationType: 'User', + CFBundleDisplayName: 'React Navigation Example', + Path: appPath, + }, + }), + 'JSON', + ' exit 0', + 'fi', + 'exit 1', + '', + ].join('\n'), + 'utf8', + ); + await fs.writeFile( + plutilPath, + [ + '#!/bin/sh', + 'case "$5" in', + [ + ' *AgentDeviceRunner.app/Info.plist) echo ', + '\'{"CFBundleURLTypes":[{"CFBundleURLSchemes":["rne"]}]}\' ;;', + ].join(''), + [ + ' *ReactNavigationExample.app/Info.plist) echo ', + '\'{"CFBundleURLTypes":[{"CFBundleURLSchemes":["rne"]}]}\' ;;', + ].join(''), + ' *) echo "{}" ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + 'utf8', + ); + await fs.chmod(xcrunPath, 0o755); + await fs.chmod(plutilPath, 0o755); + + const previousPath = process.env.PATH; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + + try { + const bundleId = await resolveIosSimulatorDeepLinkBundleId( + IOS_TEST_SIMULATOR, + 'rne://navigator-layout', + ); + assert.equal(bundleId, 'org.reactnavigation.playground'); + } finally { + process.env.PATH = previousPath; + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test('installIosInstallablePath invalidates cached display-name bundle matches', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-install-cache-')); const xcrunPath = path.join(tmpDir, 'xcrun'); diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index d02bfd115..cbd95dc22 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { DeviceInfo } from '../../utils/device.ts'; import { AppError } from '../../utils/errors.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; @@ -66,6 +67,7 @@ const ALIASES: Record = { settings: 'com.apple.Preferences', }; const IOS_SIMULATOR_CONSOLE_CAPTURE_MS = 25_000; +const AGENT_DEVICE_RUNNER_BUNDLE_PREFIX = 'com.callstack.agentdevice.runner'; const iosAppResolutionCache = createAppResolutionCache(); let cachedSimctlPrivacyServices: Set | null = null; @@ -125,6 +127,43 @@ export async function resolveIosApp(device: DeviceInfo, app: string): Promise { + if (device.platform !== 'ios' || device.kind !== 'simulator') return undefined; + const scheme = parseUrlScheme(url); + if (!scheme) return undefined; + + const apps = await listSimulatorAppMetadata(device); + const matches: SimulatorAppMetadata[] = []; + for (const app of apps) { + if (app.bundleId.startsWith(AGENT_DEVICE_RUNNER_BUNDLE_PREFIX)) continue; + if (!app.path) continue; + const schemes = await readIosSimulatorAppUrlSchemes(path.join(app.path, 'Info.plist')); + if (schemes.has(scheme)) { + matches.push(app); + } + } + + const userMatches = matches.filter((app) => app.applicationType === 'User'); + if (userMatches.length === 1) return userMatches[0]?.bundleId; + if (userMatches.length > 1) return undefined; + return matches.length === 1 ? matches[0]?.bundleId : undefined; +} + +function parseUrlScheme(url: string): string | undefined { + const match = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(url.trim()); + return match?.[1]?.toLowerCase(); +} + // fallow-ignore-next-line complexity export async function openIosApp( device: DeviceInfo, @@ -564,17 +603,40 @@ export async function listIosApps(device: DeviceInfo, filter: AppsFilter): Promi } export async function listSimulatorApps(device: DeviceInfo): Promise { + const apps = await listSimulatorAppMetadata(device); + return apps.map((app) => ({ + bundleId: app.bundleId, + name: app.name, + })); +} + +async function listSimulatorAppMetadata(device: DeviceInfo): Promise { const result = await runSimctl(device, ['listapps', device.id], { allowFailure: true }); const stdout = result.stdout as string; const trimmed = stdout.trim(); if (!trimmed) return []; - let parsed: Record | null = null; + let parsed: Record< + string, + { + ApplicationType?: string; + Bundle?: string; + CFBundleDisplayName?: string; + CFBundleName?: string; + Path?: string; + } + > | null = null; if (trimmed.startsWith('{')) { try { parsed = JSON.parse(trimmed) as Record< string, - { CFBundleDisplayName?: string; CFBundleName?: string } + { + ApplicationType?: string; + Bundle?: string; + CFBundleDisplayName?: string; + CFBundleName?: string; + Path?: string; + } >; } catch { parsed = null; @@ -590,7 +652,13 @@ export async function listSimulatorApps(device: DeviceInfo): Promise; } } catch { @@ -599,10 +667,53 @@ export async function listSimulatorApps(device: DeviceInfo): Promise ({ - bundleId, - name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId, - })); + return Object.entries(parsed).map(([bundleId, info]) => { + const appPath = resolveSimulatorAppPath(info); + return { + bundleId, + name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId, + ...(appPath ? { path: appPath } : {}), + ...(info.ApplicationType ? { applicationType: info.ApplicationType } : {}), + }; + }); +} + +function resolveSimulatorAppPath(info: { Bundle?: string; Path?: string }): string | undefined { + if (info.Path) return info.Path; + if (!info.Bundle) return undefined; + try { + return fileURLToPath(info.Bundle); + } catch { + return undefined; + } +} + +async function readIosSimulatorAppUrlSchemes(infoPlistPath: string): Promise> { + const result = await runAppleToolCommand( + 'plutil', + ['-convert', 'json', '-o', '-', infoPlistPath], + { + allowFailure: true, + }, + ); + if (result.exitCode !== 0) return new Set(); + try { + const parsed = JSON.parse(result.stdout) as { + CFBundleURLTypes?: Array<{ CFBundleURLSchemes?: unknown }>; + }; + const schemes = new Set(); + for (const urlType of parsed.CFBundleURLTypes ?? []) { + if (!Array.isArray(urlType.CFBundleURLSchemes)) continue; + for (const scheme of urlType.CFBundleURLSchemes) { + if (typeof scheme === 'string' && scheme.trim()) { + schemes.add(scheme.trim().toLowerCase()); + } + } + } + return schemes; + } catch { + return new Set(); + } } function parseMacOsPermissionTarget(value: string | undefined): MacOsPermissionTarget { From 01273bd464871ba87a2951af42f10fc08c4823ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 14:22:31 +0200 Subject: [PATCH 05/13] fix: speed up Android Maestro snapshots --- .../SnapshotInstrumentation.java | 35 +++- .../maestro/__tests__/replay-flow.test.ts | 16 +- .../__tests__/runtime-assertions.test.ts | 90 ++++++++ .../__tests__/runtime-geometry.test.ts | 19 ++ .../maestro/__tests__/runtime-targets.test.ts | 194 ++++++++++++++++++ src/compat/maestro/command-mapper.ts | 2 +- src/compat/maestro/runtime-assertions.ts | 22 +- src/compat/maestro/runtime-geometry.ts | 2 + src/compat/maestro/runtime-targets.ts | 53 ++++- .../android/__tests__/snapshot-helper.test.ts | 77 +++++++ .../android/__tests__/snapshot.test.ts | 100 +++++++-- .../android/snapshot-helper-capture.ts | 69 +++++-- .../android/snapshot-helper-types.ts | 5 +- src/platforms/android/snapshot.ts | 51 ++++- src/platforms/android/ui-hierarchy.ts | 49 +++++ src/utils/__tests__/args.test.ts | 3 + .../__tests__/selector-is-predicates.test.ts | 44 ++++ src/utils/cli-help.ts | 2 + src/utils/selector-is-predicates.ts | 10 +- 19 files changed, 782 insertions(+), 61 deletions(-) create mode 100644 src/compat/maestro/__tests__/runtime-assertions.test.ts diff --git a/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java b/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java index ad211a2ec..a3915e547 100644 --- a/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java +++ b/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java @@ -7,6 +7,7 @@ import android.os.Bundle; import android.util.Base64; import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; import android.view.accessibility.AccessibilityWindowInfo; import java.io.File; import java.io.FileOutputStream; @@ -22,8 +23,8 @@ public final class SnapshotInstrumentation extends Instrumentation { private static final String OUTPUT_FORMAT = "uiautomator-xml"; private static final String HELPER_API_VERSION = "1"; private static final int CHUNK_SIZE = 2 * 1024; - private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 500; - private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 100; + private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 25; + private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 25; private static final long DEFAULT_TIMEOUT_MS = 8_000; private static final int DEFAULT_MAX_DEPTH = 128; private static final int DEFAULT_MAX_NODES = 5_000; @@ -47,6 +48,7 @@ public void onStart() { int maxDepth = readIntArgument(arguments, "maxDepth", DEFAULT_MAX_DEPTH); int maxNodes = readIntArgument(arguments, "maxNodes", DEFAULT_MAX_NODES); String outputPath = readStringArgument(arguments, "outputPath"); + boolean emitChunks = readBooleanArgument(arguments, "emitChunks", true); Bundle result = new Bundle(); result.putString("agentDeviceProtocol", PROTOCOL); result.putString("helperApiVersion", HELPER_API_VERSION); @@ -62,7 +64,9 @@ public void onStart() { CaptureResult capture = captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes); writeOutputFile(outputPath, capture.xml); - emitChunks(capture.xml); + if (emitChunks) { + emitChunks(capture.xml); + } result.putString("ok", "true"); result.putString("rootPresent", Boolean.toString(capture.rootPresent)); result.putString("captureMode", capture.captureMode); @@ -343,6 +347,14 @@ private static void appendNode( appendAttribute(xml, "focusable", Boolean.toString(node.isFocusable())); appendAttribute(xml, "focused", Boolean.toString(node.isFocused())); appendAttribute(xml, "scrollable", Boolean.toString(node.isScrollable())); + appendAttribute( + xml, + "can-scroll-forward", + Boolean.toString(hasAccessibilityAction(node, AccessibilityAction.ACTION_SCROLL_FORWARD))); + appendAttribute( + xml, + "can-scroll-backward", + Boolean.toString(hasAccessibilityAction(node, AccessibilityAction.ACTION_SCROLL_BACKWARD))); appendAttribute(xml, "long-clickable", Boolean.toString(node.isLongClickable())); appendAttribute(xml, "password", Boolean.toString(node.isPassword())); appendAttribute(xml, "selected", Boolean.toString(node.isSelected())); @@ -394,6 +406,12 @@ private static void appendAttribute(StringBuilder xml, String name, CharSequence xml.append('"'); } + private static boolean hasAccessibilityAction( + AccessibilityNodeInfo node, AccessibilityAction action) { + List actions = node.getActionList(); + return actions != null && actions.contains(action); + } + private static void appendEscaped(StringBuilder xml, String value) { for (int index = 0; index < value.length(); index += 1) { char character = value.charAt(index); @@ -459,6 +477,17 @@ private static int readIntArgument(Bundle arguments, String name, int fallback) } } + private static boolean readBooleanArgument(Bundle arguments, String name, boolean fallback) { + if (arguments == null) { + return fallback; + } + String raw = arguments.getString(name); + if (raw == null || raw.trim().isEmpty()) { + return fallback; + } + return Boolean.parseBoolean(raw.trim()); + } + private static final class CaptureStats { int nodeCount; boolean truncated; diff --git a/src/compat/maestro/__tests__/replay-flow.test.ts b/src/compat/maestro/__tests__/replay-flow.test.ts index 72ab6ebe5..7542d487e 100644 --- a/src/compat/maestro/__tests__/replay-flow.test.ts +++ b/src/compat/maestro/__tests__/replay-flow.test.ts @@ -108,7 +108,7 @@ test('parseMaestroReplayFlow maps iOS openLink through the app id when available ); }); -test('parseMaestroReplayFlow maps Android openLink like Maestro without package binding', () => { +test('parseMaestroReplayFlow maps Android openLink through the app id when available', () => { const parsed = parseMaestroReplayFlow( `appId: com.callstack.agentdevicelab --- @@ -117,6 +117,20 @@ test('parseMaestroReplayFlow maps Android openLink like Maestro without package { platform: 'android' }, ); + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [['open', ['com.callstack.agentdevicelab', 'exp://localhost:8082']]], + ); +}); + +test('parseMaestroReplayFlow maps Android openLink without package binding when appId is absent', () => { + const parsed = parseMaestroReplayFlow( + `--- +- openLink: exp://localhost:8082 +`, + { platform: 'android' }, + ); + assert.deepEqual( parsed.actions.map((entry) => [entry.command, entry.positionals]), [['open', ['exp://localhost:8082']]], diff --git a/src/compat/maestro/__tests__/runtime-assertions.test.ts b/src/compat/maestro/__tests__/runtime-assertions.test.ts new file mode 100644 index 000000000..bcb314dee --- /dev/null +++ b/src/compat/maestro/__tests__/runtime-assertions.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { afterEach, test, vi } from 'vitest'; +import { invokeMaestroAssertNotVisible, invokeMaestroAssertVisible } from '../runtime-assertions.ts'; +import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('invokeMaestroAssertVisible takes a terminal snapshot when the last miss started before the deadline', async () => { + vi.spyOn(Date, 'now') + .mockReturnValueOnce(0) + .mockReturnValueOnce(1000) + .mockReturnValueOnce(6500) + .mockReturnValueOnce(6500) + .mockReturnValueOnce(6600); + + let snapshots = 0; + const response = await invokeMaestroAssertVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'android' }, + }, + positionals: ['label="Details is preloaded!"', '5000'], + invoke: async (): Promise => { + snapshots += 1; + if (snapshots === 1) { + return { ok: true, data: { createdAt: 1, nodes: [] } }; + } + return { + ok: true, + data: { + createdAt: 2, + nodes: [ + { + index: 1, + ref: 'e1', + type: 'android.widget.TextView', + label: 'Details is preloaded!', + rect: { x: 120, y: 900, width: 300, height: 60 }, + depth: 8, + }, + ], + }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.equal(snapshots, 2); + if (response.ok) { + assert.ok(response.data); + assert.equal(response.data.nodeLabel, 'Details is preloaded!'); + assert.equal(response.data.waitedMs, 6600); + } +}); + +test('invokeMaestroAssertNotVisible passes after a slow hidden sample exhausts the timeout', async () => { + vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(3500); + + const calls: DaemonRequest[] = []; + const response = await invokeMaestroAssertNotVisible({ + baseReq: { + token: 't', + session: 's', + flags: {}, + }, + positionals: ['id="tab-4"'], + invoke: async (req): Promise => { + calls.push(req); + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'Selector did not match: id="tab-4"', + details: { command: 'is', reason: 'selector_not_found' }, + }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual(calls.map((call) => call.positionals), [['visible', 'id="tab-4"']]); + if (response.ok) { + assert.ok(response.data); + assert.equal(response.data.stableSamples, 1); + assert.equal(response.data.waitedMs, 3500); + } +}); diff --git a/src/compat/maestro/__tests__/runtime-geometry.test.ts b/src/compat/maestro/__tests__/runtime-geometry.test.ts index 1af6a62ab..3be3cce8c 100644 --- a/src/compat/maestro/__tests__/runtime-geometry.test.ts +++ b/src/compat/maestro/__tests__/runtime-geometry.test.ts @@ -19,3 +19,22 @@ test('pointForMaestroTapOnTarget biases large scroll-area text containers toward expect(point).toEqual({ x: 84, y: 141 }); }); + +test('pointForMaestroTapOnTarget centers tall Android bottom-tab containers', () => { + const point = pointForMaestroTapOnTarget( + { + node: { + index: 40, + ref: 'e41', + type: 'android.widget.FrameLayout', + label: 'Albums', + rect: { x: 540, y: 2054, width: 270, height: 220 }, + }, + rect: { x: 540, y: 2054, width: 270, height: 220 }, + frame: { referenceWidth: 1080, referenceHeight: 2340 }, + }, + true, + ); + + expect(point).toEqual({ x: 675, y: 2164 }); +}); diff --git a/src/compat/maestro/__tests__/runtime-targets.test.ts b/src/compat/maestro/__tests__/runtime-targets.test.ts index 578524e16..8cf3df0ae 100644 --- a/src/compat/maestro/__tests__/runtime-targets.test.ts +++ b/src/compat/maestro/__tests__/runtime-targets.test.ts @@ -197,6 +197,124 @@ test('resolveVisibleMaestroNodeFromSnapshot requires visible text matches to be }); }); +test('resolveVisibleMaestroNodeFromSnapshot ignores Android rectless hidden navigation labels', () => { + const snapshot: SnapshotState = { + createdAt: Date.now(), + nodes: [ + { + index: 1, + ref: 'e1', + type: 'android.view.ViewGroup', + label: '', + rect: { x: 0, y: 0, width: 1080, height: 2340 }, + depth: 1, + }, + { + index: 2, + ref: 'e2', + type: 'android.widget.Button', + label: 'Chat', + enabled: true, + hittable: true, + depth: 2, + parentIndex: 1, + }, + { + index: 3, + ref: 'e3', + type: 'android.widget.TextView', + label: 'Chat', + value: 'Chat', + depth: 3, + parentIndex: 2, + }, + ], + }; + + const target = resolveVisibleMaestroNodeFromSnapshot( + snapshot, + 'label="Chat" || text="Chat" || id="Chat"', + 'android', + { referenceWidth: 1080, referenceHeight: 2340 }, + ); + + expect(target).toMatchObject({ + ok: false, + message: expect.stringContaining('none were visible'), + }); +}); + +test('resolveMaestroNodeFromSnapshot prefers concrete Android tab rect over hidden drawer label', () => { + const snapshot: SnapshotState = { + createdAt: Date.now(), + nodes: [ + { + index: 1, + ref: 'e1', + type: 'android.view.ViewGroup', + label: '', + rect: { x: 0, y: 0, width: 1080, height: 2340 }, + depth: 1, + }, + { + index: 2, + ref: 'e2', + type: 'android.widget.FrameLayout', + label: 'Albums', + rect: { x: 540, y: 2054, width: 270, height: 220 }, + enabled: true, + hittable: true, + depth: 16, + parentIndex: 1, + }, + { + index: 3, + ref: 'e3', + type: 'android.view.ViewGroup', + label: '', + rect: { x: 0, y: 0, width: 816, height: 2340 }, + depth: 1, + }, + { + index: 4, + ref: 'e4', + type: 'android.widget.Button', + label: '\udb80\udeea, Albums', + enabled: true, + hittable: true, + depth: 18, + parentIndex: 3, + }, + { + index: 5, + ref: 'e5', + type: 'android.widget.TextView', + label: 'Albums', + value: 'Albums', + enabled: true, + hittable: false, + depth: 19, + parentIndex: 4, + }, + ], + }; + + const target = resolveMaestroNodeFromSnapshot( + snapshot, + 'label="Albums" || text="Albums" || id="Albums"', + {}, + 'android', + { referenceWidth: 1080, referenceHeight: 2340 }, + { promoteTapTarget: true }, + ); + + expect(target).toMatchObject({ + ok: true, + node: expect.objectContaining({ index: 2 }), + rect: { x: 540, y: 2054, width: 270, height: 220 }, + }); +}); + test('resolveMaestroNodeFromSnapshot infers missing selected tab slot from tab-strip children', () => { const snapshot: SnapshotState = { createdAt: Date.now(), @@ -286,6 +404,82 @@ test('resolveMaestroNodeFromSnapshot keeps concrete child matches over tab-strip }); }); +test('resolveMaestroNodeFromSnapshot prefers localized breadcrumb label over broad containers', () => { + const snapshot: SnapshotState = { + createdAt: Date.now(), + nodes: [ + { + index: 1, + ref: 'e1', + type: 'Other', + label: 'Article by Gandalf', + rect: { x: 0, y: 0, width: 402, height: 116.66666412353516 }, + depth: 12, + }, + { + index: 2, + ref: 'e2', + type: 'ScrollView', + label: 'Article by Gandalf', + rect: { x: 0, y: 0, width: 402, height: 116.66666666666666 }, + depth: 13, + parentIndex: 1, + }, + { + index: 3, + ref: 'e3', + type: 'Other', + label: 'Article by Gandalf', + rect: { x: 0, y: 0, width: 232.3333282470703, height: 116.33333587646484 }, + depth: 14, + parentIndex: 2, + }, + { + index: 4, + ref: 'e4', + type: 'Other', + label: 'Article by Gandalf', + rect: { x: 0, y: 0, width: 232.3333282470703, height: 116.33333587646484 }, + depth: 15, + parentIndex: 3, + }, + { + index: 5, + ref: 'e5', + type: 'Other', + label: 'Article by Gandalf', + rect: { x: 8, y: 65.33333587646484, width: 155, height: 48 }, + depth: 16, + parentIndex: 4, + }, + { + index: 6, + ref: 'e6', + type: 'Other', + label: 'Feed', + rect: { x: 170.3333282470703, y: 65.33333587646484, width: 54, height: 48 }, + depth: 16, + parentIndex: 4, + }, + ], + }; + + const target = resolveMaestroNodeFromSnapshot( + snapshot, + 'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"', + {}, + 'ios', + { referenceWidth: 402, referenceHeight: 874 }, + { promoteTapTarget: true }, + ); + + expect(target).toMatchObject({ + ok: true, + node: expect.objectContaining({ index: 5 }), + rect: { x: 8, y: 65.33333587646484, width: 155, height: 48 }, + }); +}); + test('resolveMaestroNodeFromSnapshot infers leading breadcrumb slot when selected child is omitted', () => { const snapshot: SnapshotState = { createdAt: Date.now(), diff --git a/src/compat/maestro/command-mapper.ts b/src/compat/maestro/command-mapper.ts index 36f294b40..83c8b642f 100644 --- a/src/compat/maestro/command-mapper.ts +++ b/src/compat/maestro/command-mapper.ts @@ -154,7 +154,7 @@ function convertOpenLink( ): SessionAction { const rawLink = readOpenLink(value, name); const url = resolveMaestroString(rawLink, context); - if (context.platform === 'ios' && config.appId) { + if ((context.platform === 'ios' || context.platform === 'android') && config.appId) { return action('open', [resolveMaestroString(requireAppId(config, name), context), url]); } return action('open', [url]); diff --git a/src/compat/maestro/runtime-assertions.ts b/src/compat/maestro/runtime-assertions.ts index b4645e01b..d3acecde3 100644 --- a/src/compat/maestro/runtime-assertions.ts +++ b/src/compat/maestro/runtime-assertions.ts @@ -41,7 +41,9 @@ export async function invokeMaestroAssertVisible(params: { const startedAt = Date.now(); const deadlineMs = timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs; let lastResponse: DaemonResponse | undefined; - do { + let capturedAfterDeadline = false; + while (true) { + const captureStartedAt = Date.now(); const response = await captureMaestroRawSnapshot(params); lastResponse = response; if (response.ok) { @@ -73,9 +75,16 @@ export async function invokeMaestroAssertVisible(params: { lastResponse = errorResponse('COMMAND_FAILED', target.message, { selector }); } - if (Date.now() - startedAt >= deadlineMs) break; + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= deadlineMs) { + if (!capturedAfterDeadline && captureStartedAt - startedAt < deadlineMs) { + capturedAfterDeadline = true; + continue; + } + break; + } await sleep(MAESTRO_ASSERTION_POLICY.assertVisiblePollMs); - } while (Date.now() - startedAt <= deadlineMs); + } return ( lastResponse ?? @@ -110,13 +119,18 @@ export async function invokeMaestroAssertNotVisible(params: { lastVisibleResponse = response; } else if (isMaestroVisibilityMiss(response)) { hiddenSamples += 1; - if (hiddenSamples >= 2) { + const waitedMs = Date.now() - startedAt; + if ( + hiddenSamples >= 2 || + waitedMs >= MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs + ) { return { ok: true, data: { pass: true, selector, stableSamples: hiddenSamples, + waitedMs, timeoutMs: MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs, }, }; diff --git a/src/compat/maestro/runtime-geometry.ts b/src/compat/maestro/runtime-geometry.ts index fb73424c1..0a3027ea3 100644 --- a/src/compat/maestro/runtime-geometry.ts +++ b/src/compat/maestro/runtime-geometry.ts @@ -13,6 +13,7 @@ const MAESTRO_GEOMETRY_POLICY = { largeTextContainerBias: { minWidth: 120, minHeight: 70, + maxHeight: 200, width: 168, height: 48, }, @@ -109,5 +110,6 @@ function shouldBiasMaestroVisibleTextTap( const type = normalizeType(node.type ?? ''); const scrollableTextContainer = type === 'scrollview' || type === 'scroll-area'; if (rect.height < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minHeight) return false; + if (rect.height > MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.maxHeight) return false; return type === 'cell' || type === 'other' || scrollableTextContainer; } diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index 21bce36cb..cfbb2719f 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -355,8 +355,10 @@ function resolveMaestroSnapshotMatchCandidates( const resolved = matches .map((node) => resolveMaestroSnapshotMatch(nodes, node, nodeByIndex)) .filter((candidate): candidate is MaestroResolvedSnapshotMatch => Boolean(candidate)); + const concrete = resolved.filter((candidate) => !candidate.inheritedRect); + const candidates = concrete.length > 0 ? concrete : resolved; if (!visibleTextQuery || index !== undefined) return resolved; - return preferOnScreenMatches(resolved, frame, requireOnScreen); + return preferOnScreenMatches(candidates, frame, requireOnScreen); } function resolveMaestroSnapshotMatch( @@ -376,7 +378,11 @@ function chooseMaestroSnapshotMatch( promoteTapTarget: boolean, ): MaestroResolvedSnapshotMatch | null { if (index !== undefined) return candidates[index] ?? null; - const best = selectBestMaestroSnapshotMatch(candidates, visibleTextQuery); + const best = + promoteTapTarget && visibleTextQuery + ? selectLocalizedMaestroVisibleTextMatch(nodes, candidates, visibleTextQuery) ?? + selectBestMaestroSnapshotMatch(candidates, visibleTextQuery) + : selectBestMaestroSnapshotMatch(candidates, visibleTextQuery); if (!promoteTapTarget || !visibleTextQuery || !best) return best; return inferMaestroMissingTabSlotMatch(nodes, best, visibleTextQuery) ?? best; } @@ -392,6 +398,49 @@ function selectBestMaestroSnapshotMatch( ); } +function selectLocalizedMaestroVisibleTextMatch( + nodes: SnapshotState['nodes'], + candidates: MaestroResolvedSnapshotMatch[], + query: string, +): MaestroResolvedSnapshotMatch | null { + const exactMatches = candidates.filter( + (candidate) => maestroVisibleTextMatchRank(candidate.node, query) <= 1, + ); + if (exactMatches.length < 2) return null; + + const nodeByIndex = buildSnapshotNodeByIndex(nodes); + const localized = exactMatches.filter( + (candidate) => + isLocalizedMaestroVisibleTextCandidate(candidate) && + exactMatches.some((container) => + isMaestroVisibleTextContainerForCandidate(nodes, container, candidate, nodeByIndex), + ), + ); + + return selectBestMaestroSnapshotMatch(localized, query); +} + +function isLocalizedMaestroVisibleTextCandidate(match: MaestroResolvedSnapshotMatch): boolean { + return ( + match.rect.width >= 16 && + match.rect.width <= 260 && + match.rect.height >= 24 && + match.rect.height <= 80 + ); +} + +function isMaestroVisibleTextContainerForCandidate( + nodes: SnapshotState['nodes'], + container: MaestroResolvedSnapshotMatch, + candidate: MaestroResolvedSnapshotMatch, + nodeByIndex: SnapshotNodeByIndex, +): boolean { + if (container.node.index === candidate.node.index) return false; + if (!rectContains(container.rect, candidate.rect)) return false; + if (rectArea(container.rect) < rectArea(candidate.rect) * 2) return false; + return isDescendantOfSnapshotNode(nodes, candidate.node, container.node, nodeByIndex); +} + function preferOnScreenMatches( matches: MaestroResolvedSnapshotMatch[], frame: TouchReferenceFrame | undefined, diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts index 45fed04ae..79331de0a 100644 --- a/src/platforms/android/__tests__/snapshot-helper.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper.test.ts @@ -589,6 +589,83 @@ test('captureAndroidSnapshotWithHelper uses injected adb executor', async () => assert.equal(result.metadata.maxNodes, 100); }); +test('captureAndroidSnapshotWithHelper can read output file when chunks are disabled', async () => { + const adbCalls: string[][] = []; + const outputPath = '/sdcard/Download/agent-device-snapshot.xml'; + const adb: AndroidAdbExecutor = async (args) => { + adbCalls.push(args); + if (args[0] === 'shell' && args[1] === 'cat') { + assert.equal(args[2], outputPath); + return { + exitCode: 0, + stdout: '', + stderr: '', + }; + } + if (args[0] === 'shell' && args[1] === 'rm') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + return { + exitCode: 0, + stdout: helperOutput({ + chunks: [], + result: { + ok: 'true', + outputFormat: 'uiautomator-xml', + waitForIdleTimeoutMs: '10', + waitForIdleQuietMs: '5', + timeoutMs: '9000', + maxDepth: '64', + maxNodes: '100', + }, + }), + stderr: '', + }; + }; + + const result = await captureAndroidSnapshotWithHelper({ + adb, + waitForIdleTimeoutMs: 10, + waitForIdleQuietMs: 5, + timeoutMs: 9000, + maxDepth: 64, + maxNodes: 100, + outputPath, + emitChunks: false, + }); + + assert.deepEqual(adbCalls[0], [ + 'shell', + 'am', + 'instrument', + '-w', + '-e', + 'waitForIdleTimeoutMs', + '10', + '-e', + 'waitForIdleQuietMs', + '5', + '-e', + 'timeoutMs', + '9000', + '-e', + 'maxDepth', + '64', + '-e', + 'maxNodes', + '100', + '-e', + 'outputPath', + outputPath, + '-e', + 'emitChunks', + 'false', + 'com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation', + ]); + assert.equal(result.xml, ''); + assert.equal(result.metadata.maxNodes, 100); +}); + test('captureAndroidSnapshotWithHelper gives adb command overhead beyond helper timeout', async () => { let commandTimeoutMs: number | undefined; await captureAndroidSnapshotWithHelper({ diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index a592add21..5ef911411 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -338,7 +338,7 @@ test('snapshotAndroid uses injected helper artifact before stock uiautomator', a assert.equal(result.androidSnapshot.installReason, 'current'); assert.equal(result.androidSnapshot.captureMode, 'interactive-windows'); assert.equal(result.androidSnapshot.windowCount, 1); - assert.deepEqual(timeouts, [30000, 30000]); + assert.deepEqual(timeouts, [30000, 30000, 5000]); assert.equal(mockRunCmd.mock.calls.length, 0); }); @@ -367,6 +367,10 @@ test('snapshotAndroid forwards alert-style helper idle timeout override', async assert.ok(instrumentArgs); assert.equal(instrumentArgs[instrumentArgs.indexOf('waitForIdleTimeoutMs') + 1], '0'); + assert.match( + instrumentArgs[instrumentArgs.indexOf('outputPath') + 1] ?? '', + /^\/sdcard\/Download\/agent-device-snapshot-/, + ); }); test('snapshotAndroid emits helper phase diagnostics', async () => { @@ -448,7 +452,7 @@ test('snapshotAndroid resolves helper adb through scoped provider', async () => assert.equal(result.androidSnapshot.backend, 'android-helper'); assert.deepEqual( adbCalls.map((args) => args[0]), - ['shell', 'shell'], + ['shell', 'shell', 'shell'], ); assert.equal(mockRunCmd.mock.calls.length, 0); }); @@ -651,25 +655,21 @@ test('snapshotAndroid skips stock fallback after killed helper instrumentation', assert.equal(stockAttempted, false); }); -test('snapshotAndroid skips stock fallback after unparseable helper output', async () => { - let stockAttempted = false; +test('snapshotAndroid falls back to stock dump after unparseable helper output', async () => { + const stockXml = + ''; const helperAdb = createHelperAdb({ instrument: async () => ({ exitCode: 0, stdout: '', stderr: '' }), - stock: async () => { - stockAttempted = true; - throw new Error('stock fallback should not run'); - }, + stock: async () => ({ exitCode: 0, stdout: stockXml, stderr: '' }), }); - await assert.rejects( - () => snapshotAndroidWithHelper(helperAdb), - (error) => { - assert.match((error as Error).message, /Android snapshot helper output could not be parsed/); - assert.match((error as Error).message, /Stock UIAutomator fallback was skipped/); - return true; - }, + const result = await snapshotAndroidWithHelper(helperAdb); + + assert.equal(result.androidSnapshot.backend, 'uiautomator-dump'); + assert.match( + result.androidSnapshot.fallbackReason ?? '', + /Android snapshot helper output could not be parsed/, ); - assert.equal(stockAttempted, false); }); test('snapshotAndroid falls back to stock dump after helper adb timeout', async () => { @@ -1041,6 +1041,74 @@ test('snapshotAndroid skips hidden content hints when disabled', async () => { ); }); +test('snapshotAndroid uses helper scroll action hints without activity dump', async () => { + const xml = ` + + + + + + + + +`; + + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (isAndroidSdkVersionCommand(args)) { + return { exitCode: 0, stdout: '35', stderr: '' }; + } + if (args.includes('exec-out')) { + return { exitCode: 0, stdout: xml, stderr: '' }; + } + if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) { + throw new Error('dumpsys activity top should not run when helper action hints exist'); + } + throw new Error(`unexpected args: ${args.join(' ')}`); + }); + + const result = await snapshotAndroid(device); + const scrollArea = result.nodes.find((node) => node.type === 'android.widget.ScrollView'); + + assert.ok(scrollArea); + assert.equal(scrollArea.hiddenContentBelow, true); + assert.equal(scrollArea.hiddenContentAbove, undefined); +}); + +test('snapshotAndroid does not convert horizontal helper scroll action to vertical hints', async () => { + const xml = ` + + + + + + + + +`; + + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (isAndroidSdkVersionCommand(args)) { + return { exitCode: 0, stdout: '35', stderr: '' }; + } + if (args.includes('exec-out')) { + return { exitCode: 0, stdout: xml, stderr: '' }; + } + if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) { + throw new Error('dumpsys activity top should not run when helper action hints exist'); + } + throw new Error(`unexpected args: ${args.join(' ')}`); + }); + + const result = await snapshotAndroid(device); + const scrollArea = result.nodes.find( + (node) => node.type === 'android.widget.HorizontalScrollView', + ); + + assert.ok(scrollArea); + assert.equal(scrollArea.hiddenContentBelow, undefined); + assert.equal(scrollArea.hiddenContentAbove, undefined); +}); + test('snapshotAndroid derives hidden content hints for interactive snapshots from shared visibility semantics', async () => { const xml = ` diff --git a/src/platforms/android/snapshot-helper-capture.ts b/src/platforms/android/snapshot-helper-capture.ts index 272102052..fb633f806 100644 --- a/src/platforms/android/snapshot-helper-capture.ts +++ b/src/platforms/android/snapshot-helper-capture.ts @@ -40,6 +40,7 @@ type AndroidSnapshotHelperResolvedCaptureOptions = { packageName: string; runner: string; outputPath?: string; + emitChunks?: boolean; }; export async function captureAndroidSnapshotWithHelper( @@ -87,6 +88,7 @@ function resolveAndroidSnapshotHelperCaptureOptions( packageName, runner: withDefault(options.instrumentationRunner, `${packageName}/.SnapshotInstrumentation`), ...(options.outputPath ? { outputPath: options.outputPath } : {}), + ...(options.emitChunks !== undefined ? { emitChunks: options.emitChunks } : {}), }; } @@ -118,6 +120,7 @@ function buildAndroidSnapshotHelperArgs( 'maxNodes', String(options.maxNodes), ...(options.outputPath ? ['-e', 'outputPath', options.outputPath] : []), + ...(options.emitChunks !== undefined ? ['-e', 'emitChunks', String(options.emitChunks)] : []), options.runner, ]; } @@ -141,17 +144,23 @@ async function readFallbackHelperOutputOrThrow( result: Awaited>, error: unknown, ): Promise { - if (resolved.outputPath) { + if (error instanceof AppError && result.exitCode !== 0 && error.details?.helper) throw error; + if (result.exitCode === 0 && resolved.outputPath) { + const resultMetadata = + readHelperMetadataFromInstrumentationOutput(`${result.stdout}\n${result.stderr}`) ?? + undefined; const fileOutput = await readHelperOutputFile(options.adb, resolved.outputPath, { - waitForIdleTimeoutMs: resolved.waitForIdleTimeoutMs, - waitForIdleQuietMs: resolved.waitForIdleQuietMs, - timeoutMs: resolved.timeoutMs, - maxDepth: resolved.maxDepth, - maxNodes: resolved.maxNodes, + ...(resultMetadata ?? { + outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, + waitForIdleTimeoutMs: resolved.waitForIdleTimeoutMs, + waitForIdleQuietMs: resolved.waitForIdleQuietMs, + timeoutMs: resolved.timeoutMs, + maxDepth: resolved.maxDepth, + maxNodes: resolved.maxNodes, + }), }); if (fileOutput) return fileOutput; } - if (error instanceof AppError && result.exitCode !== 0 && error.details?.helper) throw error; throw new AppError( 'COMMAND_FAILED', result.exitCode === 0 @@ -169,33 +178,51 @@ async function readFallbackHelperOutputOrThrow( async function readHelperOutputFile( adb: AndroidSnapshotHelperCaptureOptions['adb'], outputPath: string, - metadata: Omit, + metadata: AndroidSnapshotHelperMetadata, ): Promise { - const result = await adb(['shell', 'cat', outputPath], { - allowFailure: true, - timeoutMs: 5_000, - }); - await removeHelperOutputFile(adb, outputPath); + let result: Awaited>; + try { + result = await adb(['shell', 'cat', outputPath], { + allowFailure: true, + timeoutMs: 5_000, + }); + } catch { + return undefined; + } finally { + await removeHelperOutputFile(adb, outputPath); + } if (result.exitCode !== 0) return undefined; const xml = result.stdout.trim(); if (!xml.includes('')) return undefined; return { xml, - metadata: { - ...metadata, - outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, - }, + metadata, }; } +function readHelperMetadataFromInstrumentationOutput( + output: string, +): AndroidSnapshotHelperMetadata | null { + try { + const records = parseInstrumentationRecords(output); + return readHelperMetadata(readFinalHelperResult(records.results)); + } catch { + return null; + } +} + async function removeHelperOutputFile( adb: AndroidSnapshotHelperCaptureOptions['adb'], outputPath: string, ): Promise { - await adb(['shell', 'rm', '-f', outputPath], { - allowFailure: true, - timeoutMs: 5_000, - }); + try { + await adb(['shell', 'rm', '-f', outputPath], { + allowFailure: true, + timeoutMs: 5_000, + }); + } catch { + // Cleanup is best-effort; snapshot capture should not fail because a stale temp file survived. + } } export function parseAndroidSnapshotHelperOutput(output: string): AndroidSnapshotHelperOutput { diff --git a/src/platforms/android/snapshot-helper-types.ts b/src/platforms/android/snapshot-helper-types.ts index c06430cd6..ac3bd9925 100644 --- a/src/platforms/android/snapshot-helper-types.ts +++ b/src/platforms/android/snapshot-helper-types.ts @@ -9,8 +9,8 @@ export const ANDROID_SNAPSHOT_HELPER_RUNNER = 'com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation'; export const ANDROID_SNAPSHOT_HELPER_PROTOCOL = 'android-snapshot-helper-v1'; export const ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT = 'uiautomator-xml'; -export const ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS = 500; -export const ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_QUIET_MS = 100; +export const ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS = 25; +export const ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_QUIET_MS = 25; export const ANDROID_SNAPSHOT_HELPER_COMMAND_OVERHEAD_MS = 5_000; export type { AndroidAdbExecutor } from './adb-executor.ts'; @@ -63,6 +63,7 @@ export type AndroidSnapshotHelperCaptureOptions = { maxDepth?: number; maxNodes?: number; outputPath?: string; + emitChunks?: boolean; }; export type AndroidSnapshotHelperMetadata = { diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 8663444d7..a648591d9 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -78,7 +78,7 @@ export async function snapshotAndroid( if (!options.interactiveOnly) { const parsed = parseUiHierarchy(xml, ANDROID_SNAPSHOT_MAX_NODES, options); if (includeHiddenContentHints) { - const nativeHints = await deriveScrollableContentHintsIfNeeded(device, parsed.nodes, adb); + const nativeHints = await deriveScrollableContentHintsIfNeeded(device, parsed.nodes, xml, adb); applyHiddenContentHintsToNodes(nativeHints, parsed.nodes); } return { ...parsed, androidSnapshot: capture.metadata }; @@ -91,7 +91,12 @@ export async function snapshotAndroid( }); const interactiveSnapshot = buildUiHierarchySnapshot(tree, ANDROID_SNAPSHOT_MAX_NODES, options); if (includeHiddenContentHints) { - const nativeHints = await deriveScrollableContentHintsIfNeeded(device, fullSnapshot.nodes, adb); + const nativeHints = await deriveScrollableContentHintsIfNeeded( + device, + fullSnapshot.nodes, + xml, + adb, + ); applyHiddenContentHintsToInteractiveNodes(nativeHints, fullSnapshot, interactiveSnapshot); if (nativeHints.size === 0) { const presentationHints = deriveMobileSnapshotHiddenContentHints( @@ -210,6 +215,8 @@ async function captureAndroidUiHierarchyFromHelper( options.helperWaitForIdleTimeoutMs ?? ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS, timeoutMs: HELPER_CAPTURE_TIMEOUT_MS, commandTimeoutMs: HELPER_COMMAND_TIMEOUT_MS, + outputPath: createAndroidSnapshotHelperOutputPath(), + emitChunks: false, }), { packageName: artifact.manifest.packageName, @@ -220,6 +227,11 @@ async function captureAndroidUiHierarchyFromHelper( ); } +function createAndroidSnapshotHelperOutputPath(): string { + const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + return `/sdcard/Download/agent-device-snapshot-${suffix}.xml`; +} + function formatAndroidHelperCaptureResult( capture: AndroidSnapshotHelperOutput, artifact: AndroidSnapshotHelperArtifact, @@ -287,8 +299,7 @@ function formatAndroidSnapshotHelperBusyError(error: unknown): AppError | undefi const normalized = normalizeError(error); if ( !isStructuredHelperTimeout(normalized.details?.helper, normalized.message) && - !isKilledHelperInstrumentationFailure(normalized) && - !isUnsafeStockFallbackHelperReason(normalized.message) + !isKilledHelperInstrumentationFailure(normalized) ) { return undefined; } @@ -316,10 +327,6 @@ function isKilledHelperInstrumentationFailure(error: { ); } -function isUnsafeStockFallbackHelperReason(reason: string): boolean { - return /Android snapshot helper output could not be parsed/.test(reason); -} - function readHelperMessage(helper: unknown): string | undefined { if (!helper || typeof helper !== 'object' || !('message' in helper)) return undefined; const message = String(helper.message).trim(); @@ -422,11 +429,16 @@ function getAndroidSnapshotHelperDeviceKey(device: DeviceInfo): string { async function deriveScrollableContentHintsIfNeeded( device: DeviceInfo, nodes: RawSnapshotNode[], + xml: string, adb?: AndroidAdbExecutor, ): Promise> { if (!nodes.some((node) => isScrollableType(node.type))) { return new Map(); } + const existingHints = collectExistingHiddenContentHints(nodes); + if (existingHints.size > 0 || hasAndroidScrollActionAttributes(xml)) { + return existingHints; + } const activityTopDump = await dumpActivityTop(device, adb); if (!activityTopDump) { return new Map(); @@ -434,6 +446,29 @@ async function deriveScrollableContentHintsIfNeeded( return deriveAndroidScrollableContentHints(nodes, activityTopDump); } +function hasAndroidScrollActionAttributes(xml: string): boolean { + return xml.includes(' can-scroll-forward=') || xml.includes(' can-scroll-backward='); +} + +function collectExistingHiddenContentHints( + nodes: RawSnapshotNode[], +): Map { + const hintsByIndex = new Map(); + for (const node of nodes) { + const hint: HiddenContentHint = {}; + if (node.hiddenContentAbove) { + hint.hiddenContentAbove = true; + } + if (node.hiddenContentBelow) { + hint.hiddenContentBelow = true; + } + if (hint.hiddenContentAbove || hint.hiddenContentBelow) { + hintsByIndex.set(node.index, hint); + } + } + return hintsByIndex; +} + export async function dumpUiHierarchy( device: DeviceInfo, adb = resolveAndroidAdbExecutor(device), diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index b825f0baf..27a5c55d5 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -19,6 +19,9 @@ export type AndroidUiNodeMetadata = { focusable?: boolean; focused?: boolean; password?: boolean; + scrollable?: boolean; + canScrollForward?: boolean; + canScrollBackward?: boolean; }; export function* androidUiNodes(xml: string): IterableIterator { @@ -202,6 +205,9 @@ function readNodeAttributes(node: string): Omit { focusable: boolAttr('focusable'), focused: boolAttr('focused'), password: boolAttr('password'), + scrollable: boolAttr('scrollable'), + canScrollForward: boolAttr('can-scroll-forward'), + canScrollBackward: boolAttr('can-scroll-backward'), }; } @@ -354,6 +360,9 @@ export type AndroidUiHierarchy = { parentIndex?: number; hiddenContentAbove?: boolean; hiddenContentBelow?: boolean; + scrollable?: boolean; + canScrollForward?: boolean; + canScrollBackward?: boolean; children: AndroidNode[]; }; @@ -398,6 +407,9 @@ export function parseUiHierarchyTree(xml: string): AndroidUiHierarchy { rect: attrs.rect, enabled: attrs.enabled, hittable: attrs.clickable ?? attrs.focusable, + scrollable: attrs.scrollable, + canScrollForward: attrs.canScrollForward, + canScrollBackward: attrs.canScrollBackward, depth: parent.depth + 1, parentIndex: undefined, children: [], @@ -408,9 +420,46 @@ export function parseUiHierarchyTree(xml: string): AndroidUiHierarchy { } match = tokenRegex.exec(xml); } + applyAndroidScrollActionHints(root); return root; } +function applyAndroidScrollActionHints(root: AndroidUiHierarchy): void { + const stack = [...root.children]; + while (stack.length > 0) { + const node = stack.pop() as AndroidNode; + stack.push(...node.children); + if (!isVerticalScrollableNode(node)) continue; + if (node.canScrollBackward) node.hiddenContentAbove = true; + if (node.canScrollForward) node.hiddenContentBelow = true; + } +} + +function isVerticalScrollableNode(node: AndroidNode): boolean { + if (!node.scrollable || !isScrollableType(node.type)) return false; + const type = `${node.type ?? ''}`.toLowerCase(); + if (type.includes('horizontalscrollview')) return false; + const overflow = estimateChildOverflow(node); + if (overflow && overflow.horizontal > overflow.vertical && overflow.horizontal > 16) { + return false; + } + return true; +} + +function estimateChildOverflow(node: AndroidNode): { horizontal: number; vertical: number } | null { + if (!node.rect || node.children.length === 0) return null; + const childRects = node.children.map((child) => child.rect).filter((rect) => rect !== undefined); + if (childRects.length === 0) return null; + const minX = Math.min(...childRects.map((rect) => rect.x)); + const maxX = Math.max(...childRects.map((rect) => rect.x + rect.width)); + const minY = Math.min(...childRects.map((rect) => rect.y)); + const maxY = Math.max(...childRects.map((rect) => rect.y + rect.height)); + return { + horizontal: Math.max(0, maxX - minX - node.rect.width), + vertical: Math.max(0, maxY - minY - node.rect.height), + }; +} + function shouldIncludeAndroidNode( node: AndroidNode, options: SnapshotOptions, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index d9f4933b1..78ae2e7a7 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1130,6 +1130,9 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help debugging/); assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); + assert.match(help, /same host context that owns Metro/); + assert.match(help, /sandbox probe is not authoritative/); + assert.match(help, /adb reverse only affects Android device-to-host traffic/); assert.match(help, /Keep the agent-device react-devtools prefix/); assert.match(help, /Use help react-devtools for status\/wait/); assert.match(help, /logs clear --restart/); diff --git a/src/utils/__tests__/selector-is-predicates.test.ts b/src/utils/__tests__/selector-is-predicates.test.ts index 4e3cb9730..b723cce1f 100644 --- a/src/utils/__tests__/selector-is-predicates.test.ts +++ b/src/utils/__tests__/selector-is-predicates.test.ts @@ -96,3 +96,47 @@ test('visible predicate uses visible Android ancestor geometry for rectless text assert.equal(result.pass, true); }); + +test('visible predicate does not use non-hittable Android layout ancestors for rectless text', () => { + const nodes: SnapshotNode[] = [ + { + index: 0, + ref: 'e0', + type: 'android.widget.FrameLayout', + rect: { x: 0, y: 0, width: 1080, height: 2340 }, + }, + { + index: 1, + ref: 'e1', + parentIndex: 0, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 0, width: 816, height: 2340 }, + hittable: false, + }, + { + index: 2, + ref: 'e2', + parentIndex: 1, + type: 'android.widget.Button', + label: 'Albums', + hittable: true, + }, + { + index: 3, + ref: 'e3', + parentIndex: 2, + type: 'android.widget.TextView', + label: 'Albums', + value: 'Albums', + }, + ]; + + const result = evaluateIsPredicate({ + predicate: 'visible', + node: nodes[3]!, + nodes, + platform: 'android', + }); + + assert.equal(result.pass, false); +}); diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index d08e84eaf..d7b55f608 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -382,6 +382,8 @@ React Native dev loop: agent-device find "Home" Do not use agent-device reload. Use open --relaunch for native startup reset. Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, use help react-native if the app cannot reach local Metro. + Verify Metro from the same host context that owns Metro. If a sandboxed shell cannot curl localhost:8081/status but an unrestricted host shell can, Metro is running and the sandbox probe is not authoritative. + adb reverse only affects Android device-to-host traffic. It does not prove host-to-Metro reachability, and it does not fix a redbox caused by a stale or wrong Metro/app state. Expo Go/dev clients are host shells. Use provided project URLs, verify with snapshot -i after opening, and ask instead of inventing app ids or URLs. Help workflow owns the full Expo URL command shapes. Overlays and busy RN UIs: diff --git a/src/utils/selector-is-predicates.ts b/src/utils/selector-is-predicates.ts index 0d94a6ad9..cf0181bf6 100644 --- a/src/utils/selector-is-predicates.ts +++ b/src/utils/selector-is-predicates.ts @@ -60,7 +60,7 @@ function isAssertionVisible( if (hasPositiveRect(node.rect)) return isRectVisibleInViewport(node, nodes); if (node.rect) return false; if (platform !== 'android' && node.hittable === true) return true; - const anchor = resolveVisibilityAnchor(node, nodes); + const anchor = resolveVisibilityAnchor(node, nodes, platform); if (!anchor) return false; if (!hasPositiveRect(anchor.rect)) return platform !== 'android' && anchor.hittable === true; return isRectVisibleInViewport(anchor, nodes); @@ -76,6 +76,7 @@ function isRectVisibleInViewport( function resolveVisibilityAnchor( node: SnapshotState['nodes'][number], nodes: SnapshotState['nodes'], + platform: Platform, ): SnapshotState['nodes'][number] | null { const nodesByIndex = new Map(nodes.map((entry) => [entry.index, entry])); let current = node; @@ -84,13 +85,13 @@ function resolveVisibilityAnchor( visited.add(current.index); const parent = nodesByIndex.get(current.parentIndex); if (!parent) break; - if (isUsefulVisibilityAnchor(parent)) return parent; + if (isUsefulVisibilityAnchor(parent, platform)) return parent; current = parent; } return null; } -function isUsefulVisibilityAnchor(node: SnapshotState['nodes'][number]): boolean { +function isUsefulVisibilityAnchor(node: SnapshotState['nodes'][number], platform: Platform): boolean { const type = normalizeType(node.type ?? ''); // These containers often report the full content frame, not the clipped on-screen geometry. if ( @@ -105,6 +106,9 @@ function isUsefulVisibilityAnchor(node: SnapshotState['nodes'][number]): boolean ) { return false; } + if (platform === 'android') { + return node.hittable === true && hasPositiveRect(node.rect); + } return node.hittable === true || hasPositiveRect(node.rect); } From 50242fde7825af75ba4b41716d646e1593acc813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 14:32:51 +0200 Subject: [PATCH 06/13] fix: resolve android snapshot ci regressions --- src/compat/maestro/runtime-assertions.ts | 127 ++++++++++++------ src/compat/maestro/runtime-targets.ts | 38 +++++- .../android/snapshot-helper-capture.ts | 45 ++++--- src/platforms/android/ui-hierarchy.ts | 13 +- 4 files changed, 154 insertions(+), 69 deletions(-) diff --git a/src/compat/maestro/runtime-assertions.ts b/src/compat/maestro/runtime-assertions.ts index d3acecde3..948923097 100644 --- a/src/compat/maestro/runtime-assertions.ts +++ b/src/compat/maestro/runtime-assertions.ts @@ -29,55 +29,22 @@ export async function invokeMaestroAssertVisible(params: { invoke: MaestroRuntimeInvoke; scope?: ReplayVarScope; }): Promise { - const [selector, timeoutValue = '5000'] = params.positionals; - if (!selector) { - return errorResponse('INVALID_ARGS', 'assertVisible requires a selector.'); - } - const timeoutMs = Number(timeoutValue); - if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { - return errorResponse('INVALID_ARGS', 'assertVisible timeout must be a non-negative number.'); - } + const args = readAssertVisibleArgs(params.positionals); + if (!args.ok) return args.response; const startedAt = Date.now(); - const deadlineMs = timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs; + const deadlineMs = args.timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs; let lastResponse: DaemonResponse | undefined; let capturedAfterDeadline = false; while (true) { const captureStartedAt = Date.now(); - const response = await captureMaestroRawSnapshot(params); - lastResponse = response; - if (response.ok) { - const snapshot = readSnapshotState(response.data); - if (!snapshot) { - return errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for assertVisible.'); - } - const target = resolveVisibleMaestroNodeFromSnapshot( - snapshot, - selector, - readMaestroSelectorPlatform(params.baseReq.flags), - getSnapshotReferenceFrame(snapshot), - ); - if (target.ok) { - return { - ok: true, - data: { - selector, - matches: target.matches, - nodeIndex: target.node.index, - nodeType: target.node.type, - nodeLabel: target.node.label, - nodeIdentifier: target.node.identifier, - rect: target.rect, - waitedMs: Date.now() - startedAt, - }, - }; - } - lastResponse = errorResponse('COMMAND_FAILED', target.message, { selector }); - } + const attempt = await readAssertVisibleAttempt(params, args.selector, startedAt); + if (attempt.done) return attempt.response; + lastResponse = attempt.response; const elapsedMs = Date.now() - startedAt; if (elapsedMs >= deadlineMs) { - if (!capturedAfterDeadline && captureStartedAt - startedAt < deadlineMs) { + if (shouldCaptureOnceAfterDeadline(capturedAfterDeadline, captureStartedAt, startedAt, deadlineMs)) { capturedAfterDeadline = true; continue; } @@ -88,13 +55,87 @@ export async function invokeMaestroAssertVisible(params: { return ( lastResponse ?? - errorResponse('COMMAND_FAILED', `Expected visible but did not match: ${selector}`, { - selector, - timeoutMs, + errorResponse('COMMAND_FAILED', `Expected visible but did not match: ${args.selector}`, { + selector: args.selector, + timeoutMs: args.timeoutMs, }) ); } +function readAssertVisibleArgs( + positionals: string[], +): + | { ok: true; selector: string; timeoutMs: number } + | { ok: false; response: DaemonResponse } { + const [selector, timeoutValue = '5000'] = positionals; + if (!selector) { + return { ok: false, response: errorResponse('INVALID_ARGS', 'assertVisible requires a selector.') }; + } + const timeoutMs = Number(timeoutValue); + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + return { + ok: false, + response: errorResponse('INVALID_ARGS', 'assertVisible timeout must be a non-negative number.'), + }; + } + return { ok: true, selector, timeoutMs }; +} + +async function readAssertVisibleAttempt( + params: { + baseReq: ReplayBaseRequest; + positionals: string[]; + invoke: MaestroRuntimeInvoke; + scope?: ReplayVarScope; + }, + selector: string, + startedAt: number, +): Promise<{ done: true; response: DaemonResponse } | { done: false; response: DaemonResponse }> { + const response = await captureMaestroRawSnapshot(params); + if (!response.ok) return { done: false, response }; + const snapshot = readSnapshotState(response.data); + if (!snapshot) { + return { + done: true, + response: errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for assertVisible.'), + }; + } + const target = resolveVisibleMaestroNodeFromSnapshot( + snapshot, + selector, + readMaestroSelectorPlatform(params.baseReq.flags), + getSnapshotReferenceFrame(snapshot), + ); + if (!target.ok) { + return { done: false, response: errorResponse('COMMAND_FAILED', target.message, { selector }) }; + } + return { + done: true, + response: { + ok: true, + data: { + selector, + matches: target.matches, + nodeIndex: target.node.index, + nodeType: target.node.type, + nodeLabel: target.node.label, + nodeIdentifier: target.node.identifier, + rect: target.rect, + waitedMs: Date.now() - startedAt, + }, + }, + }; +} + +function shouldCaptureOnceAfterDeadline( + capturedAfterDeadline: boolean, + captureStartedAt: number, + startedAt: number, + deadlineMs: number, +): boolean { + return !capturedAfterDeadline && captureStartedAt - startedAt < deadlineMs; +} + export async function invokeMaestroAssertNotVisible(params: { baseReq: ReplayBaseRequest; positionals: string[]; diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index cfbb2719f..80bd81a0e 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -378,13 +378,37 @@ function chooseMaestroSnapshotMatch( promoteTapTarget: boolean, ): MaestroResolvedSnapshotMatch | null { if (index !== undefined) return candidates[index] ?? null; - const best = - promoteTapTarget && visibleTextQuery - ? selectLocalizedMaestroVisibleTextMatch(nodes, candidates, visibleTextQuery) ?? - selectBestMaestroSnapshotMatch(candidates, visibleTextQuery) - : selectBestMaestroSnapshotMatch(candidates, visibleTextQuery); - if (!promoteTapTarget || !visibleTextQuery || !best) return best; - return inferMaestroMissingTabSlotMatch(nodes, best, visibleTextQuery) ?? best; + const best = selectPreferredMaestroSnapshotMatch( + nodes, + candidates, + visibleTextQuery, + promoteTapTarget, + ); + if (!shouldInferMaestroTabSlot(best, visibleTextQuery, promoteTapTarget)) return best; + return inferMaestroMissingTabSlotMatch(nodes, best, visibleTextQuery!) ?? best; +} + +function selectPreferredMaestroSnapshotMatch( + nodes: SnapshotState['nodes'], + candidates: MaestroResolvedSnapshotMatch[], + visibleTextQuery: string | null, + promoteTapTarget: boolean, +): MaestroResolvedSnapshotMatch | null { + if (!promoteTapTarget || !visibleTextQuery) { + return selectBestMaestroSnapshotMatch(candidates, visibleTextQuery); + } + return ( + selectLocalizedMaestroVisibleTextMatch(nodes, candidates, visibleTextQuery) ?? + selectBestMaestroSnapshotMatch(candidates, visibleTextQuery) + ); +} + +function shouldInferMaestroTabSlot( + match: MaestroResolvedSnapshotMatch | null, + visibleTextQuery: string | null, + promoteTapTarget: boolean, +): match is MaestroResolvedSnapshotMatch { + return Boolean(promoteTapTarget && visibleTextQuery && match); } function selectBestMaestroSnapshotMatch( diff --git a/src/platforms/android/snapshot-helper-capture.ts b/src/platforms/android/snapshot-helper-capture.ts index fb633f806..e1c664a45 100644 --- a/src/platforms/android/snapshot-helper-capture.ts +++ b/src/platforms/android/snapshot-helper-capture.ts @@ -145,22 +145,8 @@ async function readFallbackHelperOutputOrThrow( error: unknown, ): Promise { if (error instanceof AppError && result.exitCode !== 0 && error.details?.helper) throw error; - if (result.exitCode === 0 && resolved.outputPath) { - const resultMetadata = - readHelperMetadataFromInstrumentationOutput(`${result.stdout}\n${result.stderr}`) ?? - undefined; - const fileOutput = await readHelperOutputFile(options.adb, resolved.outputPath, { - ...(resultMetadata ?? { - outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, - waitForIdleTimeoutMs: resolved.waitForIdleTimeoutMs, - waitForIdleQuietMs: resolved.waitForIdleQuietMs, - timeoutMs: resolved.timeoutMs, - maxDepth: resolved.maxDepth, - maxNodes: resolved.maxNodes, - }), - }); - if (fileOutput) return fileOutput; - } + const fileOutput = await readFallbackHelperOutputFile(options, resolved, result); + if (fileOutput) return fileOutput; throw new AppError( 'COMMAND_FAILED', result.exitCode === 0 @@ -175,6 +161,33 @@ async function readFallbackHelperOutputOrThrow( ); } +async function readFallbackHelperOutputFile( + options: AndroidSnapshotHelperCaptureOptions, + resolved: AndroidSnapshotHelperResolvedCaptureOptions, + result: Awaited>, +): Promise { + if (result.exitCode !== 0 || !resolved.outputPath) return undefined; + return await readHelperOutputFile( + options.adb, + resolved.outputPath, + readHelperMetadataFromInstrumentationOutput(`${result.stdout}\n${result.stderr}`) ?? + fallbackAndroidSnapshotHelperMetadata(resolved), + ); +} + +function fallbackAndroidSnapshotHelperMetadata( + resolved: AndroidSnapshotHelperResolvedCaptureOptions, +): AndroidSnapshotHelperMetadata { + return { + outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, + waitForIdleTimeoutMs: resolved.waitForIdleTimeoutMs, + waitForIdleQuietMs: resolved.waitForIdleQuietMs, + timeoutMs: resolved.timeoutMs, + maxDepth: resolved.maxDepth, + maxNodes: resolved.maxNodes, + }; +} + async function readHelperOutputFile( adb: AndroidSnapshotHelperCaptureOptions['adb'], outputPath: string, diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index 27a5c55d5..87b982544 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -193,6 +193,13 @@ function readNodeAttributes(node: string): Omit { if (raw === null) return undefined; return raw === 'true'; }; + const optionalBoolAttr = ( + key: Key, + name: string, + ): Pick | {} => { + const value = boolAttr(name); + return value === undefined ? {} : { [key]: value }; + }; return { text: getAttr('text'), desc: getAttr('content-desc'), @@ -205,9 +212,9 @@ function readNodeAttributes(node: string): Omit { focusable: boolAttr('focusable'), focused: boolAttr('focused'), password: boolAttr('password'), - scrollable: boolAttr('scrollable'), - canScrollForward: boolAttr('can-scroll-forward'), - canScrollBackward: boolAttr('can-scroll-backward'), + ...optionalBoolAttr('scrollable', 'scrollable'), + ...optionalBoolAttr('canScrollForward', 'can-scroll-forward'), + ...optionalBoolAttr('canScrollBackward', 'can-scroll-backward'), }; } From 54c5f8f0ba6c0c9b81dfb3881083d1ca8edb6859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 15:14:35 +0200 Subject: [PATCH 07/13] perf: reduce android snapshot helper overhead --- .../SnapshotInstrumentation.java | 56 +++++++++------ .../android/__tests__/snapshot-helper.test.ts | 25 ++++--- .../android/__tests__/snapshot.test.ts | 43 ++++++++++-- .../android/snapshot-helper-capture.ts | 37 +++++++--- src/platforms/android/snapshot.ts | 70 ++++++++++++------- src/platforms/android/ui-hierarchy.ts | 5 ++ 6 files changed, 165 insertions(+), 71 deletions(-) diff --git a/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java b/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java index a3915e547..8a45aeede 100644 --- a/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java +++ b/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java @@ -335,29 +335,30 @@ private static void appendNode( node.getBoundsInScreen(bounds); xml.append(""); } + private static void appendNonEmptyAttribute(StringBuilder xml, String name, CharSequence value) { + if (value == null || value.length() == 0) { + return; + } + appendAttribute(xml, name, value); + } + + private static void appendTrueAttribute(StringBuilder xml, String name, boolean value) { + if (value) { + appendAttribute(xml, name, "true"); + } + } + private static void appendAttribute(StringBuilder xml, String name, CharSequence value) { String stringValue = value == null ? "" : value.toString(); xml.append(' '); diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts index 79331de0a..33fd63784 100644 --- a/src/platforms/android/__tests__/snapshot-helper.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper.test.ts @@ -594,17 +594,14 @@ test('captureAndroidSnapshotWithHelper can read output file when chunks are disa const outputPath = '/sdcard/Download/agent-device-snapshot.xml'; const adb: AndroidAdbExecutor = async (args) => { adbCalls.push(args); - if (args[0] === 'shell' && args[1] === 'cat') { - assert.equal(args[2], outputPath); + if (args[0] === 'shell' && args[1] === 'sh') { + assert.equal(args.at(-1), outputPath); return { exitCode: 0, stdout: '', stderr: '', }; } - if (args[0] === 'shell' && args[1] === 'rm') { - return { exitCode: 0, stdout: '', stderr: '' }; - } return { exitCode: 0, stdout: helperOutput({ @@ -662,6 +659,14 @@ test('captureAndroidSnapshotWithHelper can read output file when chunks are disa 'false', 'com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation', ]); + assert.deepEqual(adbCalls[1], [ + 'shell', + 'sh', + '-c', + 'cat "$1"; status=$?; rm -f "$1"; exit "$status"', + 'agent-device-snapshot-helper-output', + outputPath, + ]); assert.equal(result.xml, ''); assert.equal(result.metadata.maxNodes, 100); }); @@ -730,16 +735,13 @@ test('captureAndroidSnapshotWithHelper reads helper output file when instrumenta stderr: '', }; } - if (args[0] === 'shell' && args[1] === 'cat') { + if (args[0] === 'shell' && args[1] === 'sh') { return { exitCode: 0, stdout: '', stderr: '', }; } - if (args[0] === 'shell' && args[1] === 'rm') { - return { exitCode: 0, stdout: '', stderr: '' }; - } throw new Error(`unexpected args: ${args.join(' ')}`); }, outputPath: '/sdcard/Android/data/com.callstack.agentdevice.snapshothelper/files/test.xml', @@ -749,7 +751,10 @@ test('captureAndroidSnapshotWithHelper reads helper output file when instrumenta assert.equal(result.metadata.outputFormat, 'uiautomator-xml'); assert.deepEqual(calls.at(1), [ 'shell', - 'cat', + 'sh', + '-c', + 'cat "$1"; status=$?; rm -f "$1"; exit "$status"', + 'agent-device-snapshot-helper-output', '/sdcard/Android/data/com.callstack.agentdevice.snapshothelper/files/test.xml', ]); }); diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index 5ef911411..236a032db 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -338,7 +338,7 @@ test('snapshotAndroid uses injected helper artifact before stock uiautomator', a assert.equal(result.androidSnapshot.installReason, 'current'); assert.equal(result.androidSnapshot.captureMode, 'interactive-windows'); assert.equal(result.androidSnapshot.windowCount, 1); - assert.deepEqual(timeouts, [30000, 30000, 5000]); + assert.deepEqual(timeouts, [30000, 30000]); assert.equal(mockRunCmd.mock.calls.length, 0); }); @@ -367,10 +367,8 @@ test('snapshotAndroid forwards alert-style helper idle timeout override', async assert.ok(instrumentArgs); assert.equal(instrumentArgs[instrumentArgs.indexOf('waitForIdleTimeoutMs') + 1], '0'); - assert.match( - instrumentArgs[instrumentArgs.indexOf('outputPath') + 1] ?? '', - /^\/sdcard\/Download\/agent-device-snapshot-/, - ); + assert.equal(instrumentArgs.includes('outputPath'), false); + assert.equal(instrumentArgs.includes('emitChunks'), false); }); test('snapshotAndroid emits helper phase diagnostics', async () => { @@ -452,7 +450,7 @@ test('snapshotAndroid resolves helper adb through scoped provider', async () => assert.equal(result.androidSnapshot.backend, 'android-helper'); assert.deepEqual( adbCalls.map((args) => args[0]), - ['shell', 'shell', 'shell'], + ['shell', 'shell'], ); assert.equal(mockRunCmd.mock.calls.length, 0); }); @@ -1140,6 +1138,39 @@ test('snapshotAndroid derives hidden content hints for interactive snapshots fro assert.equal(scrollArea?.hiddenContentBelow, true); }); +test('snapshotAndroid omits zero-area interactive nodes from interactive snapshots', async () => { + const xml = ` + + + + + + + + + +`; + + mockAndroidSnapshotXml(xml); + + const result = await snapshotAndroid(device, { interactiveOnly: true }); + + assert.equal( + result.nodes.some((node) => node.label === 'Visible action'), + true, + ); + assert.equal( + result.nodes.some((node) => node.label === 'Collapsed action'), + false, + ); + assert.equal( + result.nodes.some( + (node) => node.rect !== undefined && (node.rect.width <= 0 || node.rect.height <= 0), + ), + false, + ); +}); + test('snapshotAndroid preserves bottomed-out hidden-above hints in interactive snapshots from a single aligned block', async () => { const xml = ` diff --git a/src/platforms/android/snapshot-helper-capture.ts b/src/platforms/android/snapshot-helper-capture.ts index e1c664a45..b78dbde5b 100644 --- a/src/platforms/android/snapshot-helper-capture.ts +++ b/src/platforms/android/snapshot-helper-capture.ts @@ -43,6 +43,11 @@ type AndroidSnapshotHelperResolvedCaptureOptions = { emitChunks?: boolean; }; +type AndroidSnapshotHelperReadResult = { + output: AndroidSnapshotHelperOutput; + cleanupDone: boolean; +}; + export async function captureAndroidSnapshotWithHelper( options: AndroidSnapshotHelperCaptureOptions, ): Promise { @@ -51,8 +56,10 @@ export async function captureAndroidSnapshotWithHelper( allowFailure: true, timeoutMs: resolved.commandTimeoutMs, }); - const output = await readAndroidSnapshotHelperOutput(options, resolved, result); - if (resolved.outputPath) await removeHelperOutputFile(options.adb, resolved.outputPath); + const { output, cleanupDone } = await readAndroidSnapshotHelperOutput(options, resolved, result); + if (resolved.outputPath && !cleanupDone) { + await removeHelperOutputFile(options.adb, resolved.outputPath); + } if (result.exitCode !== 0) { throw new AppError('COMMAND_FAILED', 'Android snapshot helper failed', { stdout: result.stdout, @@ -129,10 +136,13 @@ async function readAndroidSnapshotHelperOutput( options: AndroidSnapshotHelperCaptureOptions, resolved: AndroidSnapshotHelperResolvedCaptureOptions, result: Awaited>, -): Promise { +): Promise { try { // The helper can report structured ok=false details even when am exits non-zero. - return parseAndroidSnapshotHelperOutput(`${result.stdout}\n${result.stderr}`); + return { + output: parseAndroidSnapshotHelperOutput(`${result.stdout}\n${result.stderr}`), + cleanupDone: false, + }; } catch (error) { return await readFallbackHelperOutputOrThrow(options, resolved, result, error); } @@ -143,10 +153,10 @@ async function readFallbackHelperOutputOrThrow( resolved: AndroidSnapshotHelperResolvedCaptureOptions, result: Awaited>, error: unknown, -): Promise { +): Promise { if (error instanceof AppError && result.exitCode !== 0 && error.details?.helper) throw error; const fileOutput = await readFallbackHelperOutputFile(options, resolved, result); - if (fileOutput) return fileOutput; + if (fileOutput) return { output: fileOutput, cleanupDone: true }; throw new AppError( 'COMMAND_FAILED', result.exitCode === 0 @@ -195,14 +205,12 @@ async function readHelperOutputFile( ): Promise { let result: Awaited>; try { - result = await adb(['shell', 'cat', outputPath], { + result = await adb(buildReadAndRemoveHelperOutputArgs(outputPath), { allowFailure: true, timeoutMs: 5_000, }); } catch { return undefined; - } finally { - await removeHelperOutputFile(adb, outputPath); } if (result.exitCode !== 0) return undefined; const xml = result.stdout.trim(); @@ -213,6 +221,17 @@ async function readHelperOutputFile( }; } +function buildReadAndRemoveHelperOutputArgs(outputPath: string): string[] { + return [ + 'shell', + 'sh', + '-c', + 'cat "$1"; status=$?; rm -f "$1"; exit "$status"', + 'agent-device-snapshot-helper-output', + outputPath, + ]; +} + function readHelperMetadataFromInstrumentationOutput( output: string, ): AndroidSnapshotHelperMetadata | null { diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index a648591d9..6e3fc2f63 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -85,34 +85,61 @@ export async function snapshotAndroid( } const tree = parseUiHierarchyTree(xml); - const fullSnapshot = buildUiHierarchySnapshot(tree, ANDROID_SNAPSHOT_MAX_NODES, { - ...options, - interactiveOnly: false, - }); const interactiveSnapshot = buildUiHierarchySnapshot(tree, ANDROID_SNAPSHOT_MAX_NODES, options); if (includeHiddenContentHints) { - const nativeHints = await deriveScrollableContentHintsIfNeeded( + await applyHiddenContentHintsToInteractiveSnapshot({ device, - fullSnapshot.nodes, + options, + tree, xml, adb, - ); - applyHiddenContentHintsToInteractiveNodes(nativeHints, fullSnapshot, interactiveSnapshot); - if (nativeHints.size === 0) { - const presentationHints = deriveMobileSnapshotHiddenContentHints( - attachRefs(fullSnapshot.nodes), - ); - applyHiddenContentHintsToInteractiveNodes( - presentationHints, - fullSnapshot, - interactiveSnapshot, - ); - } + interactiveSnapshot, + }); } const { sourceNodes: _sourceNodes, ...snapshot } = interactiveSnapshot; return { ...snapshot, androidSnapshot: capture.metadata }; } +async function applyHiddenContentHintsToInteractiveSnapshot(params: { + device: DeviceInfo; + options: AndroidSnapshotOptions; + tree: AndroidUiHierarchy; + xml: string; + adb: AndroidAdbExecutor; + interactiveSnapshot: AndroidBuiltSnapshot; +}): Promise { + if ( + collectExistingHiddenContentHints(params.interactiveSnapshot.nodes).size > 0 || + hasAndroidScrollActionAttributes(params.xml) + ) { + return; + } + + const fullSnapshot = buildUiHierarchySnapshot(params.tree, ANDROID_SNAPSHOT_MAX_NODES, { + ...params.options, + interactiveOnly: false, + }); + const nativeHints = await deriveScrollableContentHintsIfNeeded( + params.device, + fullSnapshot.nodes, + params.xml, + params.adb, + ); + applyHiddenContentHintsToInteractiveNodes( + nativeHints, + fullSnapshot, + params.interactiveSnapshot, + ); + if (nativeHints.size === 0) { + const presentationHints = deriveMobileSnapshotHiddenContentHints(attachRefs(fullSnapshot.nodes)); + applyHiddenContentHintsToInteractiveNodes( + presentationHints, + fullSnapshot, + params.interactiveSnapshot, + ); + } +} + async function captureAndroidUiHierarchy( device: DeviceInfo, options: AndroidSnapshotOptions, @@ -215,8 +242,6 @@ async function captureAndroidUiHierarchyFromHelper( options.helperWaitForIdleTimeoutMs ?? ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS, timeoutMs: HELPER_CAPTURE_TIMEOUT_MS, commandTimeoutMs: HELPER_COMMAND_TIMEOUT_MS, - outputPath: createAndroidSnapshotHelperOutputPath(), - emitChunks: false, }), { packageName: artifact.manifest.packageName, @@ -227,11 +252,6 @@ async function captureAndroidUiHierarchyFromHelper( ); } -function createAndroidSnapshotHelperOutputPath(): string { - const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - return `/sdcard/Download/agent-device-snapshot-${suffix}.xml`; -} - function formatAndroidHelperCaptureResult( capture: AndroidSnapshotHelperOutput, artifact: AndroidSnapshotHelperArtifact, diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index 87b982544..5bbdddaaf 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -513,6 +513,7 @@ function shouldIncludeInteractiveAndroidNode( descendantHittable: boolean, ancestorCollection: boolean, ): boolean { + if (hasNonPositiveRect(node)) return false; if (node.hittable) return true; if (isScrollableType(info.type) && descendantHittable) return true; return shouldIncludeInteractiveProxyNode( @@ -535,6 +536,10 @@ function shouldIncludeInteractiveProxyNode( return ancestorHittable || descendantHittable || ancestorCollection; } +function hasNonPositiveRect(node: AndroidNode): boolean { + return Boolean(node.rect && (node.rect.width <= 0 || node.rect.height <= 0)); +} + function shouldIncludeStructuralAndroidNode( node: AndroidNode, info: AndroidNodeInclusionInfo, From 1cfb649306360f15dcce35f4ffba86eccb819123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 16:21:49 +0200 Subject: [PATCH 08/13] docs: record persistent helper session architecture --- ...002-persistent-platform-helper-sessions.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/adr/0002-persistent-platform-helper-sessions.md diff --git a/docs/adr/0002-persistent-platform-helper-sessions.md b/docs/adr/0002-persistent-platform-helper-sessions.md new file mode 100644 index 000000000..6c1d2814b --- /dev/null +++ b/docs/adr/0002-persistent-platform-helper-sessions.md @@ -0,0 +1,99 @@ +# ADR 0002: Persistent Platform Helper Sessions + +## Status + +Accepted + +## Context + +Some platform automation backends are expensive to start but cheap to reuse. iOS already uses a +long-lived XCTest runner session with an HTTP transport. That model avoids paying `xcodebuild`, +runner boot, and XCTest readiness costs for every command, while still allowing the daemon to +invalidate the runner when the device, app, bundle, or runner process changes. + +Android snapshot capture initially used a one-shot instrumentation helper. Every snapshot launched +`adb shell am instrument`, connected `UiAutomation`, captured the tree, emitted XML, and exited. +Recent Android snapshot optimizations reduced XML size, idle waiting, extra file I/O, and hidden +content hint work, but a throwaway prototype still showed that process/session startup dominates +steady-state latency: + +- launcher snapshot: one-shot p50 `227ms`, persistent socket p50 `5.8ms` +- React Navigation playground snapshot: one-shot p50 `265.7ms`, persistent socket p50 `16.5ms` + +The same pressure can appear on new platform adapters. HarmonyOS or other device backends may have +host tools, test runners, accessibility services, or bridge processes with the same shape: expensive +startup, cheap repeated commands, and a need for strict invalidation. + +## Decision + +Use persistent platform helper sessions when a backend has high startup cost and a reusable +automation context. + +A helper session is an optimization layer owned by the daemon, not a replacement for command +correctness. It may keep processes, sockets, runner state, accessibility service flags, or device +forwards warm. It must still execute each command against fresh platform state unless a separate +cache contract has explicit invalidation. + +The session pattern is: + +- start lazily on the first command that benefits from reuse +- bind the session to a device identity and helper/runner identity +- communicate through a small validated protocol with request ids and version metadata +- reuse the session while the identity and protocol remain valid +- invalidate on device disconnect, helper reinstall/version change, process exit, socket/protocol + failure, app/session identity change, or capture options that affect command semantics +- fall back to the existing one-shot path for the current command when reuse fails +- make shutdown best effort and make stale sessions disposable + +For Android snapshots, productize a persistent helper mode that keeps `UiAutomation` alive and +serves fresh snapshot requests over an `adb forward` socket. Do not add snapshot result caching as +part of that first step. The first reliable win is infrastructure reuse, not data reuse. + +For iOS, keep the XCTest runner session as the reference implementation for lifecycle and +invalidation behavior. Android does not need to copy iOS internals, but it should reuse the same +daemon-side ideas: per-device session manager, readiness checks, structured protocol errors, +fallback/invalidation, and request-scoped observability. + +For future platforms such as HarmonyOS, prefer designing adapters around this same helper-session +contract when their native automation layer is runner-like. Avoid embedding platform-specific +startup assumptions directly in command handlers. + +## Alternatives Considered + +- Keep one-shot helpers only: simplest and robust, but Android measurements show it leaves an order + of magnitude of steady-state snapshot performance on the table. +- Cache snapshots in the daemon: faster for repeated reads, but unsafe after mutations, animations, + navigation, system dialogs, or app process changes unless a mutation generation contract exists. + Cache infrastructure can be added later; it should not be mixed with helper-session reuse. +- Promote an abstract cross-platform runner immediately: tempting, but premature. iOS XCTest, + Android instrumentation, macOS helper, Linux AT-SPI, and future HarmonyOS backends have different + startup and transport mechanics. Share the daemon lifecycle contract first, then extract common + code only where repetition appears. +- Replace Android instrumentation with a normal app service: potentially useful, but Android + `UiAutomation` access is instrumentation-owned. A persistent instrumentation process keeps the + required privilege model while removing repeated process startup. + +## Consequences + +Persistent helper sessions should be measured before being productized. A prototype or benchmark +should show meaningful wall-clock improvement on a realistic app state, not just a trivial screen. + +Session managers need more lifecycle tests than one-shot helpers: startup, ready protocol, reuse, +timeout, malformed response, helper version mismatch, device disconnect, install invalidation, +shutdown, and one-shot fallback. + +Observability should report whether a command used a persistent session, started one, reused one, +invalidated one, or fell back to one-shot. This keeps CI and user bug reports diagnosable when a +fast path fails. + +Persistent sessions should not make direct interactive commands unexpectedly slow. Use short +connect/request timeouts for the persistent path, then fall back to the existing one-shot timeout +budget. + +The daemon remains the owner of session lifecycle. Platform modules may expose helper-session +operations, but command handlers should not directly manage long-lived helper processes or raw host +tool state. + +This ADR does not require every backend to implement a persistent session. It defines the preferred +shape when the backend has the same startup/reuse economics that iOS and Android snapshots now +demonstrate. From 61cae9cd783e54b8ac8b0ce140c873f8dd313462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 16:31:21 +0200 Subject: [PATCH 09/13] chore: address snapshot session review --- .../SnapshotInstrumentation.java | 5 +++ ...002-persistent-platform-helper-sessions.md | 6 ++-- .../__tests__/session-open-runtime.test.ts | 1 - src/daemon/handlers/__tests__/session.test.ts | 7 ---- .../android/snapshot-helper-capture.ts | 2 ++ src/platforms/ios/runner-client.ts | 32 ------------------- 6 files changed, 11 insertions(+), 42 deletions(-) diff --git a/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java b/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java index 8a45aeede..e09f2e7da 100644 --- a/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java +++ b/android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java @@ -23,6 +23,8 @@ public final class SnapshotInstrumentation extends Instrumentation { private static final String OUTPUT_FORMAT = "uiautomator-xml"; private static final String HELPER_API_VERSION = "1"; private static final int CHUNK_SIZE = 2 * 1024; + // Keep the default quiet window short: RN/animation-heavy apps often never become fully idle, + // and callers can still override this for alert-style flows that need a longer settle period. private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 25; private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 25; private static final long DEFAULT_TIMEOUT_MS = 8_000; @@ -334,6 +336,9 @@ private static void appendNode( Rect bounds = new Rect(); node.getBoundsInScreen(bounds); xml.append(" { return { ...actual, prewarmIosRunnerSession: vi.fn(), - prewarmIosRunnerXctestrun: vi.fn(), stopIosRunnerSession: vi.fn(async () => {}), }; }); diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index ac4d5a5a4..53d6713e0 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -18,7 +18,6 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => { return { ...actual, prewarmIosRunnerSession: vi.fn(), - prewarmIosRunnerXctestrun: vi.fn(), stopIosRunnerSession: vi.fn(async () => {}), }; }); @@ -97,7 +96,6 @@ import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; import { prewarmIosRunnerSession, - prewarmIosRunnerXctestrun, stopIosRunnerSession, } from '../../../platforms/ios/runner-client.ts'; import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts'; @@ -120,7 +118,6 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp); const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp); const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); -const mockPrewarmIosRunnerXctestrun = vi.mocked(prewarmIosRunnerXctestrun); const mockStopIosRunner = vi.mocked(stopIosRunnerSession); const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); const mockSettleSimulator = vi.mocked(settleIosSimulator); @@ -151,7 +148,6 @@ beforeEach(() => { mockClearRuntimeHints.mockReset(); mockClearRuntimeHints.mockResolvedValue(undefined); mockPrewarmIosRunnerSession.mockReset(); - mockPrewarmIosRunnerXctestrun.mockReset(); mockStopIosRunner.mockReset(); mockStopIosRunner.mockResolvedValue(undefined); mockDismissMacOsAlert.mockReset(); @@ -1962,7 +1958,6 @@ test('open custom URL on fresh iOS simulator session infers app bundle id from U expect(updated?.appName).toBe('rne://navigator-layout'); expect(dispatchedContext?.appBundleId).toBe('org.reactnavigation.playground'); expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); - expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled(); }); test('open iOS app session prewarms runner session when app bundle id is known', async () => { @@ -2001,7 +1996,6 @@ test('open iOS app session prewarms runner session when app bundle id is known', expect.objectContaining({ platform: 'ios', id: 'ios-device-1' }), expect.objectContaining({ logPath: expect.stringMatching(/daemon\.log$/) }), ); - expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled(); }); test('open iOS URL without app bundle id skips runner prewarm', async () => { @@ -2035,7 +2029,6 @@ test('open iOS URL without app bundle id skips runner prewarm', async () => { expect(response).toBeTruthy(); expect(response?.ok).toBe(true); expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled(); - expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled(); }); test('open web URL on iOS device session without active app falls back to Safari', async () => { diff --git a/src/platforms/android/snapshot-helper-capture.ts b/src/platforms/android/snapshot-helper-capture.ts index b78dbde5b..07fece368 100644 --- a/src/platforms/android/snapshot-helper-capture.ts +++ b/src/platforms/android/snapshot-helper-capture.ts @@ -126,6 +126,8 @@ function buildAndroidSnapshotHelperArgs( '-e', 'maxNodes', String(options.maxNodes), + // Default production snapshots use instrumentation status chunks. File output remains a + // fallback/testing transport for devices where status output cannot carry the payload. ...(options.outputPath ? ['-e', 'outputPath', options.outputPath] : []), ...(options.emitChunks !== undefined ? ['-e', 'emitChunks', String(options.emitChunks)] : []), options.runner, diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 3ddb7c6b6..ed0b1c3bb 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -26,7 +26,6 @@ import { resolveAppleRunnerProvider, type AppleRunnerCommandOptions, } from './runner-provider.ts'; -import { ensureXctestrun } from './runner-xctestrun.ts'; export { isRetryableRunnerError, resolveRunnerEarlyExitHint, @@ -67,37 +66,6 @@ export async function runIosRunnerCommand( return provider.runCommand(device, command, options); } -export function prewarmIosRunnerXctestrun( - device: DeviceInfo, - options: RunnerSessionOptions = {}, -): Promise | undefined { - if (device.platform !== 'ios') { - return undefined; - } - if (hasScopedAppleRunnerProvider(device, { requestId: options.requestId })) { - emitDiagnostic({ - level: 'debug', - phase: 'ios_runner_xctestrun_prewarm_skipped_scoped_provider', - data: { deviceId: device.id }, - }); - return undefined; - } - const prewarm = ensureXctestrun(device, options) - .then(() => {}) - .catch((error: unknown) => { - emitDiagnostic({ - level: 'warn', - phase: 'ios_runner_xctestrun_prewarm_failed', - data: { - deviceId: device.id, - error: error instanceof Error ? error.message : String(error), - }, - }); - }); - void prewarm; - return prewarm; -} - export function prewarmIosRunnerSession( device: DeviceInfo, options: RunnerSessionOptions = {}, From 308d2afd5d3dd2362492b0e5f27cee81446f8494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 16:54:49 +0200 Subject: [PATCH 10/13] perf: add persistent Android snapshot helper session --- android-snapshot-helper/AndroidManifest.xml | 1 + .../SnapshotInstrumentation.java | 183 +++++++- ...002-persistent-platform-helper-sessions.md | 6 +- .../__tests__/snapshot-helper-session.test.ts | 234 ++++++++++ .../android/__tests__/snapshot-helper.test.ts | 1 + .../android/snapshot-helper-capture.ts | 20 +- .../android/snapshot-helper-session.ts | 413 ++++++++++++++++++ .../android/snapshot-helper-types.ts | 8 +- src/platforms/android/snapshot-helper.ts | 5 + src/platforms/android/snapshot-types.ts | 2 + src/platforms/android/snapshot.ts | 77 +++- website/docs/docs/client-api.md | 3 + website/docs/docs/commands.md | 13 +- website/docs/docs/configuration.md | 2 +- 14 files changed, 921 insertions(+), 47 deletions(-) create mode 100644 src/platforms/android/__tests__/snapshot-helper-session.test.ts create mode 100644 src/platforms/android/snapshot-helper-session.ts diff --git a/android-snapshot-helper/AndroidManifest.xml b/android-snapshot-helper/AndroidManifest.xml index 84779e683..feab5c2d7 100644 --- a/android-snapshot-helper/AndroidManifest.xml +++ b/android-snapshot-helper/AndroidManifest.xml @@ -2,6 +2,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" package="com.callstack.agentdevice.snapshothelper"> + 0) { + runSnapshotSession( + sessionPort, waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes); + result.putString("ok", "true"); + result.putString("sessionEnded", "true"); + finishSafely(0, result); + return; + } long startedAtMs = System.currentTimeMillis(); CaptureResult capture = captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes); @@ -70,12 +78,7 @@ public void onStart() { emitChunks(capture.xml); } result.putString("ok", "true"); - result.putString("rootPresent", Boolean.toString(capture.rootPresent)); - result.putString("captureMode", capture.captureMode); - result.putString("windowCount", Integer.toString(capture.windowCount)); - result.putString("nodeCount", Integer.toString(capture.nodeCount)); - result.putString("truncated", Boolean.toString(capture.truncated)); - result.putString("elapsedMs", Long.toString(System.currentTimeMillis() - startedAtMs)); + putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs); finishSafely(0, result); } catch (Throwable error) { result.putString("ok", "false"); @@ -87,6 +90,158 @@ public void onStart() { } } + private static void putBaseMetadata( + Bundle result, + long waitForIdleTimeoutMs, + long waitForIdleQuietMs, + long timeoutMs, + int maxDepth, + int maxNodes) { + result.putString("agentDeviceProtocol", PROTOCOL); + result.putString("helperApiVersion", HELPER_API_VERSION); + result.putString("outputFormat", OUTPUT_FORMAT); + result.putString("waitForIdleTimeoutMs", Long.toString(waitForIdleTimeoutMs)); + result.putString("waitForIdleQuietMs", Long.toString(waitForIdleQuietMs)); + result.putString("timeoutMs", Long.toString(timeoutMs)); + result.putString("maxDepth", Integer.toString(maxDepth)); + result.putString("maxNodes", Integer.toString(maxNodes)); + } + + private static void putCaptureMetadata(Bundle result, CaptureResult capture, long elapsedMs) { + result.putString("rootPresent", Boolean.toString(capture.rootPresent)); + result.putString("captureMode", capture.captureMode); + result.putString("windowCount", Integer.toString(capture.windowCount)); + result.putString("nodeCount", Integer.toString(capture.nodeCount)); + result.putString("truncated", Boolean.toString(capture.truncated)); + result.putString("elapsedMs", Long.toString(elapsedMs)); + } + + private void runSnapshotSession( + int sessionPort, + long waitForIdleQuietMs, + long waitForIdleTimeoutMs, + long timeoutMs, + int maxDepth, + int maxNodes) + throws IOException { + try (ServerSocket server = + new ServerSocket(sessionPort, 1, InetAddress.getByName("127.0.0.1"))) { + Bundle ready = new Bundle(); + putBaseMetadata( + ready, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes); + ready.putString("sessionReady", "true"); + ready.putString("sessionPort", Integer.toString(sessionPort)); + sendStatus(2, ready); + + while (!Thread.currentThread().isInterrupted()) { + try (Socket socket = server.accept()) { + String command = + new BufferedReader( + new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)) + .readLine(); + if (command == null) { + writeSessionError(socket.getOutputStream(), "", "java.io.EOFException", "empty command"); + continue; + } + String[] parts = command.trim().split("\\s+", 2); + String action = parts.length > 0 ? parts[0] : ""; + String requestId = parts.length > 1 ? parts[1] : ""; + if ("quit".equals(action)) { + writeSessionOk(socket.getOutputStream(), requestId); + return; + } + if (!"snapshot".equals(action)) { + writeSessionError( + socket.getOutputStream(), + requestId, + "java.lang.IllegalArgumentException", + "unknown session command"); + continue; + } + writeSessionSnapshot( + socket.getOutputStream(), + requestId, + waitForIdleQuietMs, + waitForIdleTimeoutMs, + timeoutMs, + maxDepth, + maxNodes); + } + } + } + } + + private void writeSessionSnapshot( + OutputStream output, + String requestId, + long waitForIdleQuietMs, + long waitForIdleTimeoutMs, + long timeoutMs, + int maxDepth, + int maxNodes) + throws IOException { + Bundle result = new Bundle(); + putBaseMetadata(result, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes); + result.putString("requestId", requestId); + try { + long startedAtMs = System.currentTimeMillis(); + CaptureResult capture = + captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes); + result.putString("ok", "true"); + putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs); + result.putString("byteLength", Integer.toString(capture.xml.getBytes(StandardCharsets.UTF_8).length)); + writeSessionResponse(output, result, capture.xml); + } catch (Throwable error) { + writeSessionError( + output, + requestId, + error.getClass().getName(), + error.getMessage() == null ? error.getClass().getName() : error.getMessage()); + } + } + + private static void writeSessionOk(OutputStream output, String requestId) throws IOException { + Bundle result = new Bundle(); + result.putString("agentDeviceProtocol", PROTOCOL); + result.putString("helperApiVersion", HELPER_API_VERSION); + result.putString("outputFormat", OUTPUT_FORMAT); + result.putString("requestId", requestId); + result.putString("ok", "true"); + writeSessionResponse(output, result, ""); + } + + private static void writeSessionError( + OutputStream output, String requestId, String errorType, String message) throws IOException { + Bundle result = new Bundle(); + result.putString("agentDeviceProtocol", PROTOCOL); + result.putString("helperApiVersion", HELPER_API_VERSION); + result.putString("outputFormat", OUTPUT_FORMAT); + result.putString("requestId", requestId); + result.putString("ok", "false"); + result.putString("errorType", errorType); + result.putString("message", message); + writeSessionResponse(output, result, ""); + } + + private static void writeSessionResponse(OutputStream output, Bundle result, String body) + throws IOException { + StringBuilder headers = new StringBuilder(); + for (String key : result.keySet()) { + Object value = result.get(key); + if (value != null) { + headers.append(key).append('=').append(sanitizeHeaderValue(value.toString())).append('\n'); + } + } + headers.append('\n'); + output.write(headers.toString().getBytes(StandardCharsets.UTF_8)); + output.write(body.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private static String sanitizeHeaderValue(String value) { + return value.replace('\r', ' ').replace('\n', ' '); + } + private static String readStringArgument(Bundle arguments, String key) { if (arguments == null || !arguments.containsKey(key)) { return null; diff --git a/docs/adr/0002-persistent-platform-helper-sessions.md b/docs/adr/0002-persistent-platform-helper-sessions.md index dff43325d..acfd28586 100644 --- a/docs/adr/0002-persistent-platform-helper-sessions.md +++ b/docs/adr/0002-persistent-platform-helper-sessions.md @@ -2,7 +2,7 @@ ## Status -Accepted (implementation pending) +Accepted ## Context @@ -48,8 +48,8 @@ The session pattern is: For Android snapshots, productize a persistent helper mode that keeps `UiAutomation` alive and serves fresh snapshot requests over an `adb forward` socket. Do not add snapshot result caching as part of that first step. The first reliable win is infrastructure reuse, not data reuse. The current -PR only lands one-shot snapshot improvements and this decision record; the persistent Android -session implementation is follow-up work. +implementation keeps the existing one-shot instrumentation helper as the fallback for startup, +socket, protocol, and request failures. For iOS, keep the XCTest runner session as the reference implementation for lifecycle and invalidation behavior. Android does not need to copy iOS internals, but it should reuse the same diff --git a/src/platforms/android/__tests__/snapshot-helper-session.test.ts b/src/platforms/android/__tests__/snapshot-helper-session.test.ts new file mode 100644 index 000000000..ca1665685 --- /dev/null +++ b/src/platforms/android/__tests__/snapshot-helper-session.test.ts @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import net from 'node:net'; +import { PassThrough } from 'node:stream'; +import { afterEach, beforeEach, test } from 'vitest'; +import { + captureAndroidSnapshotWithHelperSession, + resetAndroidSnapshotHelperSessions, +} from '../snapshot-helper.ts'; +import type { AndroidAdbExecutor, AndroidAdbProcess, AndroidAdbProvider } from '../adb-executor.ts'; + +beforeEach(async () => { + delete process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION; + await resetAndroidSnapshotHelperSessions(); +}); + +afterEach(async () => { + delete process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION; + await resetAndroidSnapshotHelperSessions(); +}); + +test('returns undefined when persistent sessions are disabled', async () => { + process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION = '0'; + const calls: string[][] = []; + const provider = createSessionProvider({ calls }); + + const output = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + }); + + assert.equal(output, undefined); + assert.deepEqual(calls, []); +}); + +test('returns undefined when the adb provider cannot spawn a helper process', async () => { + const calls: string[][] = []; + const adb: AndroidAdbExecutor = async (args) => { + calls.push(args); + return { exitCode: 0, stdout: '', stderr: '' }; + }; + + const output = await captureAndroidSnapshotWithHelperSession({ adb }); + + assert.equal(output, undefined); + assert.deepEqual(calls, []); +}); + +test('starts and reuses a persistent Android snapshot helper session', async () => { + const calls: string[][] = []; + const spawnArgs: string[][] = []; + const provider = createSessionProvider({ calls, spawnArgs }); + + const first = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + helperVersion: '0.16.2', + helperVersionCode: 16002, + }); + const second = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + helperVersion: '0.16.2', + helperVersionCode: 16002, + }); + + assert.match(first?.xml ?? '', /snapshot 1/); + assert.equal(first?.metadata.transport, 'persistent-session'); + assert.equal(first?.metadata.sessionReused, false); + assert.equal(first?.metadata.elapsedMs, 7); + assert.match(second?.xml ?? '', /snapshot 2/); + assert.equal(second?.metadata.transport, 'persistent-session'); + assert.equal(second?.metadata.sessionReused, true); + assert.equal(spawnArgs.length, 1); + assert.equal(calls.filter((args) => args[0] === 'forward' && args[1]?.startsWith('tcp:')).length, 1); +}); + +test('restarts the helper session when capture options change', async () => { + const calls: string[][] = []; + const spawnArgs: string[][] = []; + const provider = createSessionProvider({ calls, spawnArgs }); + + await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + waitForIdleTimeoutMs: 25, + }); + const restarted = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + waitForIdleTimeoutMs: 50, + }); + + assert.equal(restarted?.metadata.sessionReused, false); + assert.equal(spawnArgs.length, 2); + assert.equal(calls.some((args) => args[0] === 'forward' && args[1] === '--remove'), true); +}); + +test('invalidates the helper session after a malformed response', async () => { + const calls: string[][] = []; + const provider = createSessionProvider({ calls, responseMode: 'malformed' }); + + await assert.rejects( + () => + captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + }), + { + message: 'Android snapshot helper session returned malformed output', + }, + ); + + assert.equal(calls.some((args) => args[0] === 'forward' && args[1] === '--remove'), true); +}); + +function createSessionProvider(options: { + calls: string[][]; + spawnArgs?: string[][]; + responseMode?: 'ok' | 'malformed'; +}): AndroidAdbProvider { + return { + exec: async (args) => { + options.calls.push(args); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + spawn: (args) => { + options.spawnArgs?.push(args); + const port = readSessionPort(args); + const process = new FakeAndroidProcess(); + let snapshotCount = 0; + const server = net.createServer((socket) => { + socket.once('data', (chunk) => { + const command = chunk.toString('utf8').trim(); + const [, requestId = ''] = command.split(/\s+/, 2); + if (command.startsWith('quit')) { + socket.end(sessionResponse({ requestId, body: '' })); + server.close(() => process.emitExit(0, null)); + return; + } + if (options.responseMode === 'malformed') { + socket.end('not a session response'); + return; + } + snapshotCount += 1; + const body = ``; + socket.end( + sessionResponse({ + requestId, + body, + metadata: { + waitForIdleTimeoutMs: '25', + waitForIdleQuietMs: '25', + timeoutMs: '5000', + maxDepth: '128', + maxNodes: '5000', + rootPresent: 'true', + captureMode: 'interactive-windows', + windowCount: '1', + nodeCount: '1', + truncated: 'false', + elapsedMs: '7', + }, + }), + ); + }); + }); + server.listen(port, '127.0.0.1', () => { + process.stdout.write( + [ + 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_STATUS: sessionReady=true', + 'INSTRUMENTATION_STATUS_CODE: 2', + '', + ].join('\n'), + ); + }); + process.onKill = () => { + server.close(() => process.emitExit(0, null)); + }; + return process; + }, + }; +} + +function sessionResponse(params: { + requestId: string; + body: string; + metadata?: Record; +}): string { + const bodyLength = Buffer.byteLength(params.body, 'utf8'); + const headers = { + agentDeviceProtocol: 'android-snapshot-helper-v1', + helperApiVersion: '1', + outputFormat: 'uiautomator-xml', + requestId: params.requestId, + ok: 'true', + byteLength: String(bodyLength), + ...params.metadata, + }; + return `${Object.entries(headers) + .map(([key, value]) => `${key}=${value}`) + .join('\n')}\n\n${params.body}`; +} + +function readSessionPort(args: string[]): number { + const index = args.indexOf('sessionPort'); + assert.notEqual(index, -1); + return Number(args[index + 1]); +} + +class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess { + stdin = new PassThrough(); + stdout = new PassThrough(); + stderr = new PassThrough(); + killed = false; + onKill: (() => void) | undefined; + + kill(): boolean { + this.killed = true; + this.onKill?.(); + return true; + } + + emitExit(code: number | null, signal: NodeJS.Signals | null): void { + this.emit('exit', code, signal); + this.emit('close', code, signal); + } +} diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts index 33fd63784..d62a3d64d 100644 --- a/src/platforms/android/__tests__/snapshot-helper.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper.test.ts @@ -77,6 +77,7 @@ test('parseAndroidSnapshotHelperOutput reconstructs XML chunks and metadata', () nodeCount: 1, truncated: false, elapsedMs: 42, + transport: 'instrumentation', }); }); diff --git a/src/platforms/android/snapshot-helper-capture.ts b/src/platforms/android/snapshot-helper-capture.ts index 07fece368..78231e895 100644 --- a/src/platforms/android/snapshot-helper-capture.ts +++ b/src/platforms/android/snapshot-helper-capture.ts @@ -30,7 +30,7 @@ type AndroidInstrumentationRecordState = { currentResult: Record | null; }; -type AndroidSnapshotHelperResolvedCaptureOptions = { +export type AndroidSnapshotHelperResolvedCaptureOptions = { waitForIdleTimeoutMs: number; waitForIdleQuietMs: number; timeoutMs: number; @@ -71,7 +71,7 @@ export async function captureAndroidSnapshotWithHelper( return output; } -function resolveAndroidSnapshotHelperCaptureOptions( +export function resolveAndroidSnapshotHelperCaptureOptions( options: AndroidSnapshotHelperCaptureOptions, ): AndroidSnapshotHelperResolvedCaptureOptions { const timeoutMs = withDefault(options.timeoutMs, 8_000); @@ -103,7 +103,7 @@ function withDefault(value: T | undefined, fallback: T): T { return value === undefined ? fallback : value; } -function buildAndroidSnapshotHelperArgs( +export function buildAndroidSnapshotHelperArgs( options: AndroidSnapshotHelperResolvedCaptureOptions, ): string[] { return [ @@ -197,6 +197,7 @@ function fallbackAndroidSnapshotHelperMetadata( timeoutMs: resolved.timeoutMs, maxDepth: resolved.maxDepth, maxNodes: resolved.maxNodes, + transport: 'instrumentation', }; } @@ -266,7 +267,7 @@ export function parseAndroidSnapshotHelperOutput(output: string): AndroidSnapsho return { xml, - metadata: readHelperMetadata(finalResult), + metadata: { ...readHelperMetadata(finalResult), transport: 'instrumentation' }, }; } @@ -503,7 +504,9 @@ function readKeyValue(line: string, target: Record): void { target[line.slice(0, separator)] = line.slice(separator + 1); } -function readOptionalNumber(value: string | undefined): number | undefined { +export function readAndroidSnapshotHelperMetadataNumber( + value: string | undefined, +): number | undefined { if (value === undefined) { return undefined; } @@ -511,7 +514,9 @@ function readOptionalNumber(value: string | undefined): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } -function readOptionalBoolean(value: string | undefined): boolean | undefined { +export function readAndroidSnapshotHelperMetadataBoolean( + value: string | undefined, +): boolean | undefined { if (value === 'true') { return true; } @@ -520,3 +525,6 @@ function readOptionalBoolean(value: string | undefined): boolean | undefined { } return undefined; } + +const readOptionalNumber = readAndroidSnapshotHelperMetadataNumber; +const readOptionalBoolean = readAndroidSnapshotHelperMetadataBoolean; diff --git a/src/platforms/android/snapshot-helper-session.ts b/src/platforms/android/snapshot-helper-session.ts new file mode 100644 index 000000000..cb136932b --- /dev/null +++ b/src/platforms/android/snapshot-helper-session.ts @@ -0,0 +1,413 @@ +import net from 'node:net'; +import type { AndroidAdbProcess } from './adb-executor.ts'; +import { AppError } from '../../utils/errors.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { + ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, + ANDROID_SNAPSHOT_HELPER_PROTOCOL, + type AndroidAdbExecutor, + type AndroidSnapshotHelperCaptureOptions, + type AndroidSnapshotHelperMetadata, + type AndroidSnapshotHelperOutput, +} from './snapshot-helper-types.ts'; +import { + buildAndroidSnapshotHelperArgs, + readAndroidSnapshotHelperMetadataBoolean, + readAndroidSnapshotHelperMetadataNumber, + resolveAndroidSnapshotHelperCaptureOptions, + type AndroidSnapshotHelperResolvedCaptureOptions, +} from './snapshot-helper-capture.ts'; + +const SESSION_READY_TIMEOUT_MS = 10_000; +const SESSION_STOP_TIMEOUT_MS = 1_000; +const FORWARD_TIMEOUT_MS = 5_000; + +type AndroidSnapshotHelperSession = { + identity: string; + deviceKey: string; + port: number; + adb: AndroidAdbExecutor; + process: AndroidAdbProcess; + startedAtMs: number; + capturedCount: number; +}; + +const sessions = new Map(); + +export async function captureAndroidSnapshotWithHelperSession( + options: AndroidSnapshotHelperCaptureOptions, +): Promise { + if (!isAndroidSnapshotHelperSessionEnabled() || !options.adbProvider?.spawn) { + return undefined; + } + const resolved = resolveAndroidSnapshotHelperCaptureOptions(options); + const deviceKey = options.deviceKey ?? 'android:default'; + const identity = createSessionIdentity(deviceKey, resolved, options); + let session = sessions.get(deviceKey); + if (session && session.identity !== identity) { + await stopAndroidSnapshotHelperSession(deviceKey); + session = undefined; + } + if (!session) { + session = await startAndroidSnapshotHelperSession({ + deviceKey, + identity, + options, + resolved, + }); + } + try { + const reused = session.capturedCount > 0; + const output = await requestSessionSnapshot(session, resolved); + session.capturedCount += 1; + return { + xml: output.xml, + metadata: { + ...output.metadata, + transport: 'persistent-session', + sessionReused: reused, + }, + }; + } catch (error) { + await stopAndroidSnapshotHelperSession(deviceKey); + throw error; + } +} + +export async function stopAndroidSnapshotHelperSession(deviceKey: string): Promise { + const session = sessions.get(deviceKey); + if (!session) return; + sessions.delete(deviceKey); + try { + await sendSessionCommand(session, `quit ${Date.now()}`, SESSION_STOP_TIMEOUT_MS); + } catch { + // The process may already be gone; adb forward cleanup and kill below are still enough. + } + try { + await session.process.kill('SIGTERM'); + } catch { + // Best effort. A completed instrumentation process can reject/ignore kill. + } + try { + await removeForward(session); + } catch { + // Stale forwards are harmless and the next start overwrites its chosen local port. + } + emitDiagnostic({ + phase: 'android_snapshot_helper_session_stop', + data: { + deviceKey, + port: session.port, + capturedCount: session.capturedCount, + lifetimeMs: Date.now() - session.startedAtMs, + }, + }); +} + +export async function resetAndroidSnapshotHelperSessions(): Promise { + await Promise.all([...sessions.keys()].map((deviceKey) => stopAndroidSnapshotHelperSession(deviceKey))); +} + +async function startAndroidSnapshotHelperSession(params: { + deviceKey: string; + identity: string; + options: AndroidSnapshotHelperCaptureOptions; + resolved: AndroidSnapshotHelperResolvedCaptureOptions; +}): Promise { + const port = await getFreePort(); + await params.options.adb(['forward', `tcp:${port}`, `tcp:${port}`], { + allowFailure: false, + timeoutMs: FORWARD_TIMEOUT_MS, + }); + const args = buildAndroidSnapshotHelperArgs({ + ...params.resolved, + outputPath: undefined, + emitChunks: false, + }); + const runner = args[args.length - 1]; + if (!runner) { + throw new AppError('INVALID_ARGS', 'Android snapshot helper runner was not resolved'); + } + const sessionArgs = [ + ...args.slice(0, -1), + '-e', + 'sessionPort', + String(port), + runner, + ]; + const process = params.options.adbProvider!.spawn!(sessionArgs, { + allowFailure: true, + captureOutput: false, + }); + const session: AndroidSnapshotHelperSession = { + identity: params.identity, + deviceKey: params.deviceKey, + port, + adb: params.options.adb, + process, + startedAtMs: Date.now(), + capturedCount: 0, + }; + try { + await waitForSessionReady(process, SESSION_READY_TIMEOUT_MS); + sessions.set(params.deviceKey, session); + emitDiagnostic({ + phase: 'android_snapshot_helper_session_ready', + data: { + deviceKey: params.deviceKey, + port, + packageName: params.resolved.packageName, + runner: params.resolved.runner, + }, + }); + return session; + } catch (error) { + await removeForward(session); + try { + process.kill('SIGTERM'); + } catch { + // Best effort after startup failure. + } + throw error; + } +} + +function waitForSessionReady(process: AndroidAdbProcess, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout(() => { + reject( + new AppError('COMMAND_FAILED', 'Android snapshot helper session did not become ready', { + output, + timeoutMs, + }), + ); + }, timeoutMs); + const onData = (chunk: Buffer | string) => { + output += chunk.toString(); + if ( + output.includes(`agentDeviceProtocol=${ANDROID_SNAPSHOT_HELPER_PROTOCOL}`) && + output.includes('sessionReady=true') + ) { + clearTimeout(timer); + resolve(); + } + }; + process.stdout?.on('data', onData); + process.stderr?.on('data', onData); + process.once('exit', (code, signal) => { + clearTimeout(timer); + reject( + new AppError('COMMAND_FAILED', 'Android snapshot helper session exited before ready', { + output, + exitCode: code, + signal, + }), + ); + }); + process.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +async function requestSessionSnapshot( + session: AndroidSnapshotHelperSession, + resolved: AndroidSnapshotHelperResolvedCaptureOptions, +): Promise { + const requestId = `snapshot-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const timeoutMs = Math.max(resolved.timeoutMs + 2_000, 3_000); + const response = await sendSessionCommand(session, `snapshot ${requestId}`, timeoutMs); + return parseSessionSnapshotResponse(response, requestId); +} + +function sendSessionCommand( + session: AndroidSnapshotHelperSession, + command: string, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port: session.port }); + const chunks: Buffer[] = []; + const timer = setTimeout(() => { + socket.destroy(); + reject( + new AppError('COMMAND_FAILED', 'Android snapshot helper session request timed out', { + command, + timeoutMs, + port: session.port, + }), + ); + }, timeoutMs); + socket.on('connect', () => { + socket.write(`${command}\n`); + }); + socket.on('data', (chunk) => { + chunks.push(Buffer.from(chunk)); + }); + socket.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + socket.on('close', () => { + clearTimeout(timer); + resolve(Buffer.concat(chunks).toString('utf8')); + }); + }); +} + +function parseSessionSnapshotResponse( + response: string, + requestId: string, +): AndroidSnapshotHelperOutput { + const { headers, xml } = splitSessionResponse(response); + validateSessionHeaders(headers, requestId); + validateSessionXml(headers, xml); + return { xml, metadata: readSessionMetadata(headers) }; +} + +function splitSessionResponse(response: string): { headers: Record; xml: string } { + const separator = response.indexOf('\n\n'); + if (separator < 0) { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned malformed output', { + response, + }); + } + return { + headers: parseSessionHeaders(response.slice(0, separator)), + xml: response.slice(separator + 2), + }; +} + +function validateSessionHeaders(headers: Record, requestId: string): void { + if (headers.agentDeviceProtocol !== ANDROID_SNAPSHOT_HELPER_PROTOCOL) { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned wrong protocol', { + headers, + }); + } + if (headers.outputFormat !== ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT) { + throw new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper session returned wrong output format', + { headers }, + ); + } + if (headers.requestId !== requestId) { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned stale output', { + headers, + requestId, + }); + } + if (headers.ok !== 'true') { + throw new AppError( + 'COMMAND_FAILED', + headers.message || headers.errorType || 'Android snapshot helper session returned an error', + { helper: headers }, + ); + } +} + +function validateSessionXml(headers: Record, xml: string): void { + const byteLength = readAndroidSnapshotHelperMetadataNumber(headers.byteLength); + if (byteLength !== undefined && Buffer.byteLength(xml, 'utf8') !== byteLength) { + throw new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper session returned truncated XML', + { headers, actualByteLength: Buffer.byteLength(xml, 'utf8') }, + ); + } + if (!xml.includes('')) { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper session did not return XML', { + headers, + xml, + }); + } +} + +function parseSessionHeaders(headerText: string): Record { + const headers: Record = {}; + for (const line of headerText.split(/\r?\n/)) { + const separator = line.indexOf('='); + if (separator < 0) continue; + headers[line.slice(0, separator)] = line.slice(separator + 1); + } + return headers; +} + +function readSessionMetadata(headers: Record): AndroidSnapshotHelperMetadata { + return { + helperApiVersion: headers.helperApiVersion, + outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, + waitForIdleTimeoutMs: readAndroidSnapshotHelperMetadataNumber(headers.waitForIdleTimeoutMs), + waitForIdleQuietMs: readAndroidSnapshotHelperMetadataNumber(headers.waitForIdleQuietMs), + timeoutMs: readAndroidSnapshotHelperMetadataNumber(headers.timeoutMs), + maxDepth: readAndroidSnapshotHelperMetadataNumber(headers.maxDepth), + maxNodes: readAndroidSnapshotHelperMetadataNumber(headers.maxNodes), + rootPresent: readAndroidSnapshotHelperMetadataBoolean(headers.rootPresent), + captureMode: + headers.captureMode === 'interactive-windows' || headers.captureMode === 'active-window' + ? headers.captureMode + : undefined, + windowCount: readAndroidSnapshotHelperMetadataNumber(headers.windowCount), + nodeCount: readAndroidSnapshotHelperMetadataNumber(headers.nodeCount), + truncated: readAndroidSnapshotHelperMetadataBoolean(headers.truncated), + elapsedMs: readAndroidSnapshotHelperMetadataNumber(headers.elapsedMs), + }; +} + +async function removeForward(session: AndroidSnapshotHelperSession): Promise { + await session.process.stdin?.end(); + await session.process.stdout?.destroy(); + await session.process.stderr?.destroy(); + await sessionForwardRemove(session); +} + +async function sessionForwardRemove(session: AndroidSnapshotHelperSession): Promise { + await session.adb(['forward', '--remove', `tcp:${session.port}`], { + allowFailure: true, + timeoutMs: FORWARD_TIMEOUT_MS, + }); +} + +function createSessionIdentity( + deviceKey: string, + resolved: AndroidSnapshotHelperResolvedCaptureOptions, + options: AndroidSnapshotHelperCaptureOptions, +): string { + const identity = JSON.stringify({ + deviceKey, + packageName: resolved.packageName, + runner: resolved.runner, + helperVersion: options.helperVersion, + helperVersionCode: options.helperVersionCode, + waitForIdleTimeoutMs: resolved.waitForIdleTimeoutMs, + waitForIdleQuietMs: resolved.waitForIdleQuietMs, + timeoutMs: resolved.timeoutMs, + maxDepth: resolved.maxDepth, + maxNodes: resolved.maxNodes, + }); + return identity; +} + +function isAndroidSnapshotHelperSessionEnabled(): boolean { + const value = process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION; + return value === undefined || !/^(0|false|no|off)$/i.test(value); +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate a local TCP port'))); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); +} diff --git a/src/platforms/android/snapshot-helper-types.ts b/src/platforms/android/snapshot-helper-types.ts index ac3bd9925..2c356c753 100644 --- a/src/platforms/android/snapshot-helper-types.ts +++ b/src/platforms/android/snapshot-helper-types.ts @@ -1,5 +1,5 @@ import type { RawSnapshotNode } from '../../utils/snapshot.ts'; -import type { AndroidAdbExecutor } from './adb-executor.ts'; +import type { AndroidAdbExecutor, AndroidAdbProvider } from './adb-executor.ts'; import type { AndroidSnapshotAnalysis } from './ui-hierarchy.ts'; import type { AndroidSnapshotBackendMetadata } from './snapshot-types.ts'; @@ -54,6 +54,10 @@ export type AndroidSnapshotHelperInstallResult = { export type AndroidSnapshotHelperCaptureOptions = { adb: AndroidAdbExecutor; + adbProvider?: AndroidAdbProvider; + deviceKey?: string; + helperVersion?: string; + helperVersionCode?: number; packageName?: string; instrumentationRunner?: string; waitForIdleTimeoutMs?: number; @@ -80,6 +84,8 @@ export type AndroidSnapshotHelperMetadata = { nodeCount?: number; truncated?: boolean; elapsedMs?: number; + transport?: 'instrumentation' | 'persistent-session'; + sessionReused?: boolean; }; export type AndroidSnapshotHelperOutput = { diff --git a/src/platforms/android/snapshot-helper.ts b/src/platforms/android/snapshot-helper.ts index 25ba73ba6..ec24da245 100644 --- a/src/platforms/android/snapshot-helper.ts +++ b/src/platforms/android/snapshot-helper.ts @@ -8,6 +8,11 @@ export { parseAndroidSnapshotHelperOutput, parseAndroidSnapshotHelperXml, } from './snapshot-helper-capture.ts'; +export { + captureAndroidSnapshotWithHelperSession, + resetAndroidSnapshotHelperSessions, + stopAndroidSnapshotHelperSession, +} from './snapshot-helper-session.ts'; export { ensureAndroidSnapshotHelper, forgetAndroidSnapshotHelperInstall, diff --git a/src/platforms/android/snapshot-types.ts b/src/platforms/android/snapshot-types.ts index 809c5f936..0465cf26f 100644 --- a/src/platforms/android/snapshot-types.ts +++ b/src/platforms/android/snapshot-types.ts @@ -4,6 +4,8 @@ export type AndroidSnapshotBackendMetadata = { backend: 'android-helper' | 'uiautomator-dump'; helperVersion?: string; helperApiVersion?: string; + helperTransport?: 'instrumentation' | 'persistent-session'; + helperSessionReused?: boolean; fallbackReason?: string; installReason?: 'missing' | 'outdated' | 'forced' | 'current' | 'skipped'; waitForIdleTimeoutMs?: number; diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 6e3fc2f63..432e85303 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -21,14 +21,20 @@ import { type AndroidSnapshotAnalysis, type AndroidUiHierarchy, } from './ui-hierarchy.ts'; -import { resolveAndroidAdbExecutor, resolveAndroidAdbProvider } from './adb-executor.ts'; +import { + resolveAndroidAdbExecutor, + resolveAndroidAdbProvider, + type AndroidAdbProvider, +} from './adb-executor.ts'; import { deriveAndroidScrollableContentHints } from './scroll-hints.ts'; import { captureAndroidSnapshotWithHelper, + captureAndroidSnapshotWithHelperSession, ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS, ensureAndroidSnapshotHelper, forgetAndroidSnapshotHelperInstall, parseAndroidSnapshotHelperManifest, + stopAndroidSnapshotHelperSession, type AndroidAdbExecutor, type AndroidSnapshotHelperArtifact, type AndroidSnapshotHelperInstallPolicy, @@ -57,7 +63,7 @@ const RETRYABLE_ADB_STDERR_PATTERNS = [ type AndroidSnapshotOptions = SnapshotOptions & { helperArtifact?: AndroidSnapshotHelperArtifact; helperInstallPolicy?: AndroidSnapshotHelperInstallPolicy; - helperAdb?: AndroidAdbExecutor; + helperAdb?: AndroidAdbExecutor | AndroidAdbProvider; helperWaitForIdleTimeoutMs?: number; includeHiddenContentHints?: boolean; }; @@ -71,7 +77,7 @@ export async function snapshotAndroid( analysis: AndroidSnapshotAnalysis; androidSnapshot: AndroidSnapshotBackendMetadata; }> { - const adb = resolveAndroidAdbExecutor(device, options.helperAdb); + const adb = resolveAndroidAdbProvider(device, options.helperAdb).exec; const capture = await captureAndroidUiHierarchy(device, options, adb); const xml = capture.xml; const includeHiddenContentHints = options.includeHiddenContentHints !== false; @@ -168,15 +174,25 @@ async function captureAndroidUiHierarchyWithHelper( artifact: AndroidSnapshotHelperArtifact, ): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> { const helperDeviceKey = getAndroidSnapshotHelperDeviceKey(device); + const adbProvider = resolveAndroidAdbProvider(device, options.helperAdb); try { const install = await installAndroidSnapshotHelper( - device, options, adb, + adbProvider, + artifact, + helperDeviceKey, + ); + if (install.installed) { + await stopAndroidSnapshotHelperSession(helperDeviceKey); + } + const capture = await captureAndroidUiHierarchyFromHelper( + options, + adb, + adbProvider, artifact, helperDeviceKey, ); - const capture = await captureAndroidUiHierarchyFromHelper(options, adb, artifact); return formatAndroidHelperCaptureResult(capture, artifact, install.reason); } catch (error) { return await recoverAndroidHelperCaptureFailure({ @@ -190,9 +206,9 @@ async function captureAndroidUiHierarchyWithHelper( } async function installAndroidSnapshotHelper( - device: DeviceInfo, options: AndroidSnapshotOptions, adb: AndroidAdbExecutor, + adbProvider: AndroidAdbProvider, artifact: AndroidSnapshotHelperArtifact, deviceKey: string, ): Promise { @@ -201,7 +217,7 @@ async function installAndroidSnapshotHelper( async () => await ensureAndroidSnapshotHelper({ adb, - adbProvider: resolveAndroidAdbProvider(device, options.helperAdb), + adbProvider, artifact, deviceKey, installPolicy: options.helperInstallPolicy, @@ -229,20 +245,44 @@ async function installAndroidSnapshotHelper( async function captureAndroidUiHierarchyFromHelper( options: AndroidSnapshotOptions, adb: AndroidAdbExecutor, + adbProvider: AndroidAdbProvider, artifact: AndroidSnapshotHelperArtifact, + deviceKey: string, ): Promise { - return await withDiagnosticTimer( - 'android_snapshot_helper_capture', - async () => - await captureAndroidSnapshotWithHelper({ - adb, + const captureOptions = { + adb, + adbProvider, + deviceKey, + helperVersion: artifact.manifest.version, + helperVersionCode: artifact.manifest.versionCode, + packageName: artifact.manifest.packageName, + instrumentationRunner: artifact.manifest.instrumentationRunner, + waitForIdleTimeoutMs: + options.helperWaitForIdleTimeoutMs ?? ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS, + timeoutMs: HELPER_CAPTURE_TIMEOUT_MS, + commandTimeoutMs: HELPER_COMMAND_TIMEOUT_MS, + }; + try { + const sessionCapture = await withDiagnosticTimer( + 'android_snapshot_helper_session_capture', + async () => await captureAndroidSnapshotWithHelperSession(captureOptions), + { packageName: artifact.manifest.packageName, - instrumentationRunner: artifact.manifest.instrumentationRunner, - waitForIdleTimeoutMs: - options.helperWaitForIdleTimeoutMs ?? ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS, + version: artifact.manifest.version, timeoutMs: HELPER_CAPTURE_TIMEOUT_MS, - commandTimeoutMs: HELPER_COMMAND_TIMEOUT_MS, - }), + }, + ); + if (sessionCapture) return sessionCapture; + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'android_snapshot_helper_session_fallback', + data: { reason: normalizeError(error).message }, + }); + } + return await withDiagnosticTimer( + 'android_snapshot_helper_capture', + async () => await captureAndroidSnapshotWithHelper(captureOptions), { packageName: artifact.manifest.packageName, version: artifact.manifest.version, @@ -263,6 +303,8 @@ function formatAndroidHelperCaptureResult( backend: 'android-helper', helperVersion: artifact.manifest.version, helperApiVersion: capture.metadata.helperApiVersion, + helperTransport: capture.metadata.transport, + helperSessionReused: capture.metadata.sessionReused, installReason, waitForIdleTimeoutMs: capture.metadata.waitForIdleTimeoutMs, waitForIdleQuietMs: capture.metadata.waitForIdleQuietMs, @@ -294,6 +336,7 @@ async function recoverAndroidHelperCaptureFailure(params: { phase: 'android_snapshot_helper_fallback', data: { reason: fallbackReason }, }); + await stopAndroidSnapshotHelperSession(params.helperDeviceKey); forgetAndroidSnapshotHelperInstall({ deviceKey: params.helperDeviceKey, packageName: params.artifact.manifest.packageName, diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index caacd7342..79e3c20c1 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -125,6 +125,9 @@ stdout/stderr. The option mirrors `open --launch-console` and is not valid for U Remote Android providers should import `agent-device/android-snapshot-helper` and inject their own ADB-shaped executor. The executor receives arguments after `adb`, so local callers may wrap `adb -s `, while cloud providers can route the same operations through an ADB tunnel. +Providers that can keep a long-lived instrumentation process should pass an `AndroidAdbProvider` +with `spawn`; Agent Device will use the persistent helper-session transport and fall back to +one-shot instrumentation if startup, socket, or protocol validation fails. Remote Android providers that expose stronger text entry should attach a provider-native `AndroidTextInjector` to their `AndroidAdbProvider`. Agent Device prefers that injector for diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 4bd505ac8..6f5467571 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -205,11 +205,14 @@ agent-device get attrs @e1 - iOS snapshots use XCTest on simulators and physical devices. - Android snapshots use the bundled Android snapshot helper when the npm package includes it. The first helper-backed snapshot verifies and installs the helper APK if it is missing or outdated; - helper failures fall back to stock UIAutomator and include `androidSnapshot.fallbackReason` in - typed results. Source checkouts without a bundled helper use stock UIAutomator. The helper - serializes Android interactive window roots when available, so keyboard and system-overlay nodes - can appear alongside the app root; `androidSnapshot.captureMode` and - `androidSnapshot.windowCount` describe the capture. + helper failures fall back to one-shot helper capture, then stock UIAutomator, and include + `androidSnapshot.fallbackReason` in typed results. Local ADB-backed sessions keep the helper + process warm over an `adb forward` socket and report `androidSnapshot.helperTransport` as + `persistent-session`; set `AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION=0` to disable that fast + path. Source checkouts without a bundled helper use stock UIAutomator. The helper serializes + Android interactive window roots when available, so keyboard and system-overlay nodes can appear + alongside the app root; `androidSnapshot.captureMode` and `androidSnapshot.windowCount` describe + the capture. - `diff snapshot` compares the current snapshot with the previous session baseline and then updates baseline. - `snapshot --diff` is an alias for `diff snapshot`. - Default snapshot text is an agent-facing, token-efficient view for planning and targeting actions. It may collapse helper/accessibility noise; use `--raw` or `--json` when you need the full provider tree. diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index 3852bb45a..b1fa0cb46 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -103,7 +103,7 @@ These env vars are the supported user-facing configuration surface. Other `AGENT | Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public | | App hooks and logs | `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_APP_LOG_MAX_BYTES`, `AGENT_DEVICE_APP_LOG_MAX_FILES`, `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` | Public | | Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. | -| Install/update and macOS helper | `AGENT_DEVICE_NO_UPDATE_NOTIFIER`, `AGENT_DEVICE_MACOS_HELPER_BIN` | Public operator controls | +| Install/update and platform helpers | `AGENT_DEVICE_NO_UPDATE_NOTIFIER`, `AGENT_DEVICE_MACOS_HELPER_BIN`, `AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION` | Public operator controls | ## Command-specific defaults From 3671340c6390a7ef83d1bbc01fc7ffba967e4d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 17:29:06 +0200 Subject: [PATCH 11/13] fix: stabilize Android replay interactions --- src/platforms/android/__tests__/index.test.ts | 29 +++++++++++++++ src/platforms/android/device-input-state.ts | 20 ++++++++--- src/platforms/android/fill-verification.ts | 12 +++++-- src/platforms/android/input-actions.ts | 36 ++++++++++++++++++- src/platforms/android/snapshot.ts | 10 +++++- 5 files changed, 97 insertions(+), 10 deletions(-) diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts index 0633b79a9..47e1351fa 100644 --- a/src/platforms/android/__tests__/index.test.ts +++ b/src/platforms/android/__tests__/index.test.ts @@ -1720,6 +1720,35 @@ test('getAndroidKeyboardState uses latest visibility value when dumpsys contains ); }); +test('getAndroidKeyboardState treats stale input view as hidden when the IME window is hidden', async () => { + await withMockedAdb( + 'agent-device-android-keyboard-stale-input-view-', + [ + '#!/bin/sh', + 'if [ "$1" = "-s" ]; then', + ' shift', + ' shift', + 'fi', + 'if [ "$1" = "shell" ] && [ "$2" = "dumpsys" ] && [ "$3" = "input_method" ]; then', + ' echo "mInputShown=false"', + ' echo "mDecorViewVisible=false mWindowVisible=false mInShowWindow=false"', + ' echo "mIsInputViewShown=true"', + ' echo "inputType=0x21"', + ' exit 0', + 'fi', + 'echo "unexpected args: $@" >&2', + 'exit 1', + '', + ].join('\n'), + async ({ device }) => { + const state = await getAndroidKeyboardState(device); + assert.equal(state.visible, false); + assert.equal(state.inputType, '0x21'); + assert.equal(state.type, 'email'); + }, + ); +}); + test('dismissAndroidKeyboard skips keyevent when keyboard is already hidden', async () => { await withMockedAdb( 'agent-device-android-keyboard-dismiss-hidden-', diff --git a/src/platforms/android/device-input-state.ts b/src/platforms/android/device-input-state.ts index cee9b45c8..e1671f62e 100644 --- a/src/platforms/android/device-input-state.ts +++ b/src/platforms/android/device-input-state.ts @@ -185,7 +185,8 @@ function parseLastDumpsysValue(stdout: string, pattern: RegExp): string | undefi function parseAndroidKeyboardVisibility(stdout: string): boolean | null { const latestByKey = new Map(); - const pattern = /\b(mInputShown|mIsInputViewShown|isInputViewShown)=([a-zA-Z]+)\b/g; + const pattern = + /\b(mInputShown|mIsInputViewShown|isInputViewShown|mDecorViewVisible|mWindowVisible|mInShowWindow)=([a-zA-Z]+)\b/g; for (const match of stdout.matchAll(pattern)) { const key = match[1]; const value = match[2]?.toLowerCase(); @@ -193,10 +194,19 @@ function parseAndroidKeyboardVisibility(stdout: string): boolean | null { latestByKey.set(key, value === 'true'); } if (latestByKey.size === 0) return null; - for (const visible of latestByKey.values()) { - if (visible) return true; - } - return false; + + const windowVisible = + latestByKey.get('mWindowVisible') ?? + latestByKey.get('mDecorViewVisible') ?? + latestByKey.get('mInShowWindow'); + if (windowVisible !== undefined) return windowVisible; + + const inputShown = latestByKey.get('mInputShown'); + if (inputShown !== undefined) return inputShown; + + const inputViewShown = + latestByKey.get('mIsInputViewShown') ?? latestByKey.get('isInputViewShown'); + return inputViewShown ?? null; } function classifyAndroidKeyboardType(inputType: string): AndroidKeyboardType { diff --git a/src/platforms/android/fill-verification.ts b/src/platforms/android/fill-verification.ts index a26ea899b..b8bae828a 100644 --- a/src/platforms/android/fill-verification.ts +++ b/src/platforms/android/fill-verification.ts @@ -11,7 +11,7 @@ import { import { sleep } from './adb.ts'; import { getAndroidKeyboardState } from './device-input-state.ts'; import { isAndroidInputMethodOwnedNode } from './input-ownership.ts'; -import { dumpUiHierarchy } from './snapshot.ts'; +import { captureAndroidUiHierarchyXml } from './snapshot.ts'; import { androidUiNodes, type AndroidUiNodeMetadata } from './ui-hierarchy.ts'; export type AndroidFillVerificationNode = FillDiagnosticNode & { @@ -90,7 +90,7 @@ export async function readAndroidTextAtPoint( x: number, y: number, ): Promise { - return readAndroidTextAtPointInHierarchy(await dumpUiHierarchy(device), x, y); + return readAndroidTextAtPointInHierarchy(await captureAndroidUiHierarchyXml(device), x, y); } export function verifyAndroidFilledTextInHierarchy( @@ -154,7 +154,13 @@ async function inspectAndroidFilledText( expected: string, context: AndroidFillVerificationContext, ): Promise { - return verifyAndroidFilledTextInHierarchy(await dumpUiHierarchy(device), x, y, expected, context); + return verifyAndroidFilledTextInHierarchy( + await captureAndroidUiHierarchyXml(device), + x, + y, + expected, + context, + ); } function inspectAndroidTextAtPointInHierarchy( diff --git a/src/platforms/android/input-actions.ts b/src/platforms/android/input-actions.ts index 4a43fd173..1ec70efa3 100644 --- a/src/platforms/android/input-actions.ts +++ b/src/platforms/android/input-actions.ts @@ -6,6 +6,8 @@ import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll- import { runAndroidAdb, sleep } from './adb.ts'; import { resolveAndroidTextInjector } from './adb-executor.ts'; import { getAndroidKeyboardState, type AndroidKeyboardState } from './device-input-state.ts'; +import { captureAndroidUiHierarchyXml } from './snapshot.ts'; +import { androidUiNodes } from './ui-hierarchy.ts'; import { androidFillFailureDetails, androidFillFailureMessage, @@ -206,7 +208,7 @@ export async function scrollAndroid( direction: ScrollDirection, options?: { amount?: number; pixels?: number }, ): Promise> { - const size = await getAndroidScreenSize(device); + const size = await getAndroidGestureViewportSize(device); const plan = buildScrollGesturePlan({ direction, amount: options?.amount, @@ -290,6 +292,38 @@ export async function getAndroidScreenSize( return { width: Number(match[1]), height: Number(match[2]) }; } +async function getAndroidGestureViewportSize( + device: DeviceInfo, +): Promise<{ width: number; height: number }> { + try { + const xml = await captureAndroidUiHierarchyXml(device); + const viewport = largestAndroidUiNodeRect(xml); + if (viewport) return viewport; + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'android_gesture_viewport_probe_failed', + data: { + error: error instanceof Error ? error.message : String(error), + }, + }); + } + return await getAndroidScreenSize(device); +} + +function largestAndroidUiNodeRect(xml: string): { width: number; height: number } | null { + let largest: { width: number; height: number; area: number } | null = null; + for (const node of androidUiNodes(xml)) { + const rect = node.rect; + if (!rect || rect.width <= 0 || rect.height <= 0) continue; + const area = rect.width * rect.height; + if (!largest || area > largest.area) { + largest = { width: rect.x + rect.width, height: rect.y + rect.height, area }; + } + } + return largest ? { width: largest.width, height: largest.height } : null; +} + const ANDROID_INPUT_TEXT_CHUNK_SIZE = 8; async function typeAndroidShell( diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 432e85303..698f27904 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -60,7 +60,7 @@ const RETRYABLE_ADB_STDERR_PATTERNS = [ 'no such file or directory', ] as const; -type AndroidSnapshotOptions = SnapshotOptions & { +export type AndroidSnapshotOptions = SnapshotOptions & { helperArtifact?: AndroidSnapshotHelperArtifact; helperInstallPolicy?: AndroidSnapshotHelperInstallPolicy; helperAdb?: AndroidAdbExecutor | AndroidAdbProvider; @@ -68,6 +68,14 @@ type AndroidSnapshotOptions = SnapshotOptions & { includeHiddenContentHints?: boolean; }; +export async function captureAndroidUiHierarchyXml( + device: DeviceInfo, + options: AndroidSnapshotOptions = {}, +): Promise { + const adb = resolveAndroidAdbProvider(device, options.helperAdb).exec; + return (await captureAndroidUiHierarchy(device, options, adb)).xml; +} + export async function snapshotAndroid( device: DeviceInfo, options: AndroidSnapshotOptions = {}, From 27548f0afe4f8d715563b04c430995da7225b492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 18:50:59 +0200 Subject: [PATCH 12/13] fix: stabilize Maestro visibility resolution --- .../__tests__/runtime-assertions.test.ts | 49 ++++++++++-- .../maestro/__tests__/runtime-targets.test.ts | 54 +++++++++++++ src/compat/maestro/runtime-assertions.ts | 76 ++++++++++++++----- src/compat/maestro/runtime-targets.ts | 28 ++++++- .../__tests__/session-replay-vars.test.ts | 48 +++++++----- 5 files changed, 206 insertions(+), 49 deletions(-) diff --git a/src/compat/maestro/__tests__/runtime-assertions.test.ts b/src/compat/maestro/__tests__/runtime-assertions.test.ts index bcb314dee..10e64c32f 100644 --- a/src/compat/maestro/__tests__/runtime-assertions.test.ts +++ b/src/compat/maestro/__tests__/runtime-assertions.test.ts @@ -70,21 +70,58 @@ test('invokeMaestroAssertNotVisible passes after a slow hidden sample exhausts t invoke: async (req): Promise => { calls.push(req); return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: 'Selector did not match: id="tab-4"', - details: { command: 'is', reason: 'selector_not_found' }, + ok: true, + data: { + createdAt: 1, + nodes: [], }, }; }, }); assert.equal(response.ok, true); - assert.deepEqual(calls.map((call) => call.positionals), [['visible', 'id="tab-4"']]); + assert.deepEqual(calls.map((call) => [call.command, call.positionals]), [ + ['snapshot', []], + ]); if (response.ok) { assert.ok(response.data); assert.equal(response.data.stableSamples, 1); assert.equal(response.data.waitedMs, 3500); } }); + +test('invokeMaestroAssertNotVisible ignores matched nodes without visible rects', async () => { + vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(3500); + + const response = await invokeMaestroAssertNotVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'android' }, + }, + positionals: ['label="📌" || text="📌" || id="📌"'], + invoke: async (): Promise => ({ + ok: true, + data: { + createdAt: 1, + nodes: [ + { + index: 1, + ref: 'e1', + type: 'android.widget.TextView', + label: '📌', + value: '📌', + enabled: true, + depth: 21, + }, + ], + }, + }), + }); + + assert.equal(response.ok, true); + if (response.ok) { + assert.ok(response.data); + assert.equal(response.data.stableSamples, 1); + } +}); diff --git a/src/compat/maestro/__tests__/runtime-targets.test.ts b/src/compat/maestro/__tests__/runtime-targets.test.ts index 8cf3df0ae..d5d198f4a 100644 --- a/src/compat/maestro/__tests__/runtime-targets.test.ts +++ b/src/compat/maestro/__tests__/runtime-targets.test.ts @@ -315,6 +315,60 @@ test('resolveMaestroNodeFromSnapshot prefers concrete Android tab rect over hidd }); }); +test('resolveMaestroNodeFromSnapshot prefers exact Android tab label over normalized header icon text', () => { + const snapshot: SnapshotState = { + createdAt: Date.now(), + nodes: [ + { + index: 1, + ref: 'e1', + type: 'android.widget.FrameLayout', + label: 'Search', + rect: { x: 810, y: 2054, width: 270, height: 132 }, + enabled: true, + hittable: true, + depth: 16, + }, + { + index: 2, + ref: 'e2', + type: 'android.widget.Button', + label: 'search', + rect: { x: 673, y: 165, width: 132, height: 132 }, + enabled: true, + hittable: true, + depth: 22, + }, + { + index: 3, + ref: 'e3', + type: 'android.widget.TextView', + label: 'search', + value: 'search', + rect: { x: 706, y: 198, width: 66, height: 66 }, + enabled: true, + depth: 23, + parentIndex: 2, + }, + ], + }; + + const target = resolveMaestroNodeFromSnapshot( + snapshot, + 'label="Search" || text="Search" || id="Search"', + {}, + 'android', + { referenceWidth: 1080, referenceHeight: 2340 }, + { promoteTapTarget: true }, + ); + + expect(target).toMatchObject({ + ok: true, + node: expect.objectContaining({ index: 1 }), + rect: { x: 810, y: 2054, width: 270, height: 132 }, + }); +}); + test('resolveMaestroNodeFromSnapshot infers missing selected tab slot from tab-strip children', () => { const snapshot: SnapshotState = { createdAt: Date.now(), diff --git a/src/compat/maestro/runtime-assertions.ts b/src/compat/maestro/runtime-assertions.ts index 948923097..70dd80bdb 100644 --- a/src/compat/maestro/runtime-assertions.ts +++ b/src/compat/maestro/runtime-assertions.ts @@ -149,16 +149,11 @@ export async function invokeMaestroAssertNotVisible(params: { let hiddenSamples = 0; let lastVisibleResponse: DaemonResponse | undefined; while (Date.now() - startedAt <= MAESTRO_ASSERTION_POLICY.assertNotVisibleTimeoutMs) { - const response = await params.invoke({ - ...params.baseReq, - command: 'is', - positionals: ['visible', selector], - flags: { ...params.baseReq.flags, noRecord: true }, - }); - if (response.ok) { + const attempt = await readAssertNotVisibleAttempt(params, selector); + if (attempt.visible) { hiddenSamples = 0; - lastVisibleResponse = response; - } else if (isMaestroVisibilityMiss(response)) { + lastVisibleResponse = attempt.response; + } else if (attempt.hidden) { hiddenSamples += 1; const waitedMs = Date.now() - startedAt; if ( @@ -177,7 +172,7 @@ export async function invokeMaestroAssertNotVisible(params: { }; } } else { - return response; + return attempt.response; } await sleep(MAESTRO_ASSERTION_POLICY.assertNotVisiblePollMs); } @@ -188,6 +183,59 @@ export async function invokeMaestroAssertNotVisible(params: { }); } +async function readAssertNotVisibleAttempt( + params: { + baseReq: ReplayBaseRequest; + positionals: string[]; + invoke: MaestroRuntimeInvoke; + }, + selector: string, +): Promise< + | { visible: true; hidden: false; response: DaemonResponse } + | { visible: false; hidden: true; response: DaemonResponse } + | { visible: false; hidden: false; response: DaemonResponse } +> { + const response = await captureMaestroRawSnapshot(params); + if (!response.ok) return { visible: false, hidden: false, response }; + const snapshot = readSnapshotState(response.data); + if (!snapshot) { + return { + visible: false, + hidden: false, + response: errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for assertNotVisible.'), + }; + } + const target = resolveVisibleMaestroNodeFromSnapshot( + snapshot, + selector, + readMaestroSelectorPlatform(params.baseReq.flags), + getSnapshotReferenceFrame(snapshot), + ); + if (!target.ok) { + return { + visible: false, + hidden: true, + response: errorResponse('COMMAND_FAILED', target.message, { selector }), + }; + } + return { + visible: true, + hidden: false, + response: { + ok: true, + data: { + selector, + matches: target.matches, + nodeIndex: target.node.index, + nodeType: target.node.type, + nodeLabel: target.node.label, + nodeIdentifier: target.node.identifier, + rect: target.rect, + }, + }, + }; +} + export async function invokeMaestroWaitForAnimationToEnd(params: { baseReq: ReplayBaseRequest; positionals: string[]; @@ -215,14 +263,6 @@ export async function invokeMaestroWaitForAnimationToEnd(params: { : { ok: true, data: { stable: false, timeoutMs } }; } -function isMaestroVisibilityMiss(response: Extract): boolean { - const details = response.error.details; - return ( - details?.command === 'is' && - (details.reason === 'selector_not_found' || details.reason === 'predicate_failed') - ); -} - function readAnimationPollResult( response: DaemonResponse, previousSignature: string | undefined, diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index 80bd81a0e..b0a366fa3 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -428,15 +428,35 @@ function selectLocalizedMaestroVisibleTextMatch( query: string, ): MaestroResolvedSnapshotMatch | null { const exactMatches = candidates.filter( - (candidate) => maestroVisibleTextMatchRank(candidate.node, query) <= 1, + (candidate) => maestroVisibleTextMatchRank(candidate.node, query) === 0, ); - if (exactMatches.length < 2) return null; + if (exactMatches.length >= 2) { + const localizedExact = selectLocalizedMaestroVisibleTextMatchFromCandidates( + nodes, + exactMatches, + query, + ); + if (localizedExact) return localizedExact; + } + + const normalizedMatches = candidates.filter( + (candidate) => maestroVisibleTextMatchRank(candidate.node, query) === 1, + ); + if (exactMatches.length > 0 || normalizedMatches.length < 2) return null; + + return selectLocalizedMaestroVisibleTextMatchFromCandidates(nodes, normalizedMatches, query); +} +function selectLocalizedMaestroVisibleTextMatchFromCandidates( + nodes: SnapshotState['nodes'], + candidates: MaestroResolvedSnapshotMatch[], + query: string, +): MaestroResolvedSnapshotMatch | null { const nodeByIndex = buildSnapshotNodeByIndex(nodes); - const localized = exactMatches.filter( + const localized = candidates.filter( (candidate) => isLocalizedMaestroVisibleTextCandidate(candidate) && - exactMatches.some((container) => + candidates.some((container) => isMaestroVisibleTextContainerForCandidate(nodes, container, candidate, nodeByIndex), ), ); diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index 03cae20c8..8196dc708 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -888,11 +888,10 @@ test('runReplayScriptFile treats absent Maestro assertNotVisible targets as pass invoke: async (req) => { calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: 'Selector did not match', - details: { command: 'is', reason: 'selector_not_found' }, + ok: true, + data: { + createdAt: 1, + nodes: [], }, }; }, @@ -902,14 +901,8 @@ test('runReplayScriptFile treats absent Maestro assertNotVisible targets as pass assert.deepEqual( calls.map((call) => [call.command, call.positionals]), [ - [ - 'is', - ['visible', 'label="Archived banner" || text="Archived banner" || id="Archived banner"'], - ], - [ - 'is', - ['visible', 'label="Archived banner" || text="Archived banner" || id="Archived banner"'], - ], + ['snapshot', []], + ['snapshot', []], ], ); assert.equal(calls[0]?.flags?.noRecord, true); @@ -940,21 +933,34 @@ test('runReplayScriptFile propagates Maestro assertNotVisible infrastructure fai test('runReplayScriptFile waits briefly for Maestro assertNotVisible to stabilize', async () => { const calls: CapturedInvocation[] = []; - let visibleChecks = 0; + let snapshots = 0; const { response } = await runReplayFixture({ label: 'maestro-assert-not-visible-stable', script: ['appId: demo.app', '---', '- assertNotVisible: Archived banner', ''].join('\n'), flags: { replayBackend: 'maestro' }, invoke: async (req) => { calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); - visibleChecks += 1; - if (visibleChecks === 1) return { ok: true, data: { pass: true } }; + snapshots += 1; + if (snapshots === 1) { + return { + ok: true, + data: { + createdAt: 1, + nodes: [ + { + index: 1, + label: 'Archived banner', + rect: { x: 10, y: 20, width: 180, height: 44 }, + }, + ], + }, + }; + } return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: 'is visible failed', - details: { command: 'is', reason: 'predicate_failed' }, + ok: true, + data: { + createdAt: snapshots, + nodes: [], }, }; }, From cebbedd88d25bcd206c1cb90508cb3a3206e00e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 18:58:10 +0200 Subject: [PATCH 13/13] refactor: simplify Android keyboard parsing --- src/platforms/android/device-input-state.ts | 156 +++++++++++++------- 1 file changed, 101 insertions(+), 55 deletions(-) diff --git a/src/platforms/android/device-input-state.ts b/src/platforms/android/device-input-state.ts index e1671f62e..3f57675b4 100644 --- a/src/platforms/android/device-input-state.ts +++ b/src/platforms/android/device-input-state.ts @@ -25,6 +25,28 @@ const ANDROID_TEXT_VARIATION_VISIBLE_PASSWORD = 0x00000090; const ANDROID_KEYBOARD_DISMISS_MAX_ATTEMPTS = 2; const ANDROID_KEYBOARD_DISMISS_RETRY_DELAY_MS = 120; const ANDROID_KEYCODE_ESCAPE = '111'; +const ANDROID_KEYBOARD_VISIBILITY_KEYS = [ + 'mInputShown', + 'mIsInputViewShown', + 'isInputViewShown', + 'mDecorViewVisible', + 'mWindowVisible', + 'mInShowWindow', +]; +const ANDROID_KEYBOARD_CLASS_BY_INPUT_CLASS = new Map([ + [ANDROID_INPUT_TYPE_CLASS_NUMBER, 'number'], + [ANDROID_INPUT_TYPE_CLASS_PHONE, 'phone'], + [ANDROID_INPUT_TYPE_CLASS_DATETIME, 'datetime'], +]); +const ANDROID_EMAIL_TEXT_VARIATIONS = new Set([ + ANDROID_TEXT_VARIATION_EMAIL_ADDRESS, + ANDROID_TEXT_VARIATION_WEB_EMAIL_ADDRESS, +]); +const ANDROID_PASSWORD_TEXT_VARIATIONS = new Set([ + ANDROID_TEXT_VARIATION_PASSWORD, + ANDROID_TEXT_VARIATION_WEB_PASSWORD, + ANDROID_TEXT_VARIATION_VISIBLE_PASSWORD, +]); type AndroidKeyboardType = | 'text' @@ -122,22 +144,8 @@ export async function dismissAndroidKeyboardWithAdb( } function parseAndroidKeyboardState(stdout: string): AndroidKeyboardState { - const visibility = parseAndroidKeyboardVisibility(stdout); - let visible = visibility ?? false; - if (visibility === null) { - const imeWindowVisibility = stdout.match(/\bmImeWindowVis=0x([0-9a-fA-F]+)\b/); - if (imeWindowVisibility?.[1]) { - const flags = Number.parseInt(imeWindowVisibility[1], 16); - if (!Number.isNaN(flags)) { - visible = (flags & 0x1) !== 0; - } - } - } - - const inputTypeMatches = Array.from(stdout.matchAll(/\binputType=0x([0-9a-fA-F]+)\b/gi)); - const lastInputType = - inputTypeMatches.length > 0 ? inputTypeMatches[inputTypeMatches.length - 1]?.[1] : undefined; - const inputType = lastInputType ? `0x${lastInputType.toLowerCase()}` : undefined; + const visible = parseAndroidKeyboardVisibility(stdout) ?? parseLegacyImeWindowVisibility(stdout); + const inputType = parseLastAndroidInputType(stdout); const focusedPackage = parseLastDumpsysValue(stdout, /\bpackageName=([A-Za-z0-9_.]+)\b/g); const focusedResourceId = parseLastDumpsysValue( stdout, @@ -149,23 +157,10 @@ function parseAndroidKeyboardState(stdout: string): AndroidKeyboardState { focusedResourceId, inputMethodPackage, ); - if ( - !inputMethodPackage && - (isFallbackAndroidInputMethodPackage(focusedPackage) || - isFallbackAndroidInputMethodResource(focusedResourceId)) - ) { - emitDiagnostic({ - level: 'warn', - phase: 'android_input_ownership_fallback', - data: { - focusedPackage, - focusedResourceId, - }, - }); - } + emitAndroidInputOwnershipFallbackDiagnostic(focusedPackage, focusedResourceId, inputMethodPackage); return { - visible, + visible: visible ?? false, inputType, type: inputType ? classifyAndroidKeyboardType(inputType) : undefined, inputMethodPackage, @@ -175,6 +170,22 @@ function parseAndroidKeyboardState(stdout: string): AndroidKeyboardState { }; } +function parseLegacyImeWindowVisibility(stdout: string): boolean | null { + const imeWindowVisibility = stdout.match(/\bmImeWindowVis=0x([0-9a-fA-F]+)\b/); + const rawFlags = imeWindowVisibility?.[1]; + if (!rawFlags) return null; + + const flags = Number.parseInt(rawFlags, 16); + if (Number.isNaN(flags)) return null; + + return (flags & 0x1) !== 0; +} + +function parseLastAndroidInputType(stdout: string): string | undefined { + const value = parseLastDumpsysValue(stdout, /\binputType=0x([0-9a-fA-F]+)\b/gi); + return value ? `0x${value.toLowerCase()}` : undefined; +} + function parseLastDumpsysValue(stdout: string, pattern: RegExp): string | undefined { let value: string | undefined; for (const match of stdout.matchAll(pattern)) { @@ -183,55 +194,90 @@ function parseLastDumpsysValue(stdout: string, pattern: RegExp): string | undefi return value; } +function emitAndroidInputOwnershipFallbackDiagnostic( + focusedPackage: string | undefined, + focusedResourceId: string | undefined, + inputMethodPackage: string | undefined, +): void { + if (inputMethodPackage) return; + if ( + !isFallbackAndroidInputMethodPackage(focusedPackage) && + !isFallbackAndroidInputMethodResource(focusedResourceId) + ) { + return; + } + + emitDiagnostic({ + level: 'warn', + phase: 'android_input_ownership_fallback', + data: { + focusedPackage, + focusedResourceId, + }, + }); +} + function parseAndroidKeyboardVisibility(stdout: string): boolean | null { + const latestByKey = parseLatestBooleanDumpsysValues(stdout, ANDROID_KEYBOARD_VISIBILITY_KEYS); + return resolveAndroidKeyboardVisibility(latestByKey); +} + +function parseLatestBooleanDumpsysValues(stdout: string, keys: string[]): Map { const latestByKey = new Map(); - const pattern = - /\b(mInputShown|mIsInputViewShown|isInputViewShown|mDecorViewVisible|mWindowVisible|mInShowWindow)=([a-zA-Z]+)\b/g; + const pattern = new RegExp(`\\b(${keys.join('|')})=([a-zA-Z]+)\\b`, 'g'); for (const match of stdout.matchAll(pattern)) { const key = match[1]; const value = match[2]?.toLowerCase(); if (!key || (value !== 'true' && value !== 'false')) continue; latestByKey.set(key, value === 'true'); } + return latestByKey; +} + +function resolveAndroidKeyboardVisibility(latestByKey: Map): boolean | null { if (latestByKey.size === 0) return null; - const windowVisible = - latestByKey.get('mWindowVisible') ?? - latestByKey.get('mDecorViewVisible') ?? - latestByKey.get('mInShowWindow'); + const windowVisible = firstDefinedBoolean(latestByKey, [ + 'mWindowVisible', + 'mDecorViewVisible', + 'mInShowWindow', + ]); if (windowVisible !== undefined) return windowVisible; const inputShown = latestByKey.get('mInputShown'); if (inputShown !== undefined) return inputShown; - const inputViewShown = - latestByKey.get('mIsInputViewShown') ?? latestByKey.get('isInputViewShown'); + const inputViewShown = firstDefinedBoolean(latestByKey, [ + 'mIsInputViewShown', + 'isInputViewShown', + ]); return inputViewShown ?? null; } +function firstDefinedBoolean( + values: Map, + keys: readonly string[], +): boolean | undefined { + for (const key of keys) { + const value = values.get(key); + if (value !== undefined) return value; + } + return undefined; +} + function classifyAndroidKeyboardType(inputType: string): AndroidKeyboardType { const parsed = Number.parseInt(inputType.replace(/^0x/i, ''), 16); if (Number.isNaN(parsed)) return 'unknown'; + const inputClass = parsed & ANDROID_INPUT_TYPE_CLASS_MASK; - if (inputClass === ANDROID_INPUT_TYPE_CLASS_NUMBER) return 'number'; - if (inputClass === ANDROID_INPUT_TYPE_CLASS_PHONE) return 'phone'; - if (inputClass === ANDROID_INPUT_TYPE_CLASS_DATETIME) return 'datetime'; + const knownInputClass = ANDROID_KEYBOARD_CLASS_BY_INPUT_CLASS.get(inputClass); + if (knownInputClass) return knownInputClass; if (inputClass !== ANDROID_INPUT_TYPE_CLASS_TEXT) return 'unknown'; const variation = parsed & ANDROID_INPUT_TYPE_VARIATION_MASK; - if ( - variation === ANDROID_TEXT_VARIATION_EMAIL_ADDRESS || - variation === ANDROID_TEXT_VARIATION_WEB_EMAIL_ADDRESS - ) { - return 'email'; - } - if ( - variation === ANDROID_TEXT_VARIATION_PASSWORD || - variation === ANDROID_TEXT_VARIATION_WEB_PASSWORD || - variation === ANDROID_TEXT_VARIATION_VISIBLE_PASSWORD - ) { - return 'password'; - } + if (ANDROID_EMAIL_TEXT_VARIATIONS.has(variation)) return 'email'; + if (ANDROID_PASSWORD_TEXT_VARIATIONS.has(variation)) return 'password'; + return 'text'; }