diff --git a/docs/adr/0002-persistent-platform-helper-sessions.md b/docs/adr/0002-persistent-platform-helper-sessions.md index acfd28586..08369e570 100644 --- a/docs/adr/0002-persistent-platform-helper-sessions.md +++ b/docs/adr/0002-persistent-platform-helper-sessions.md @@ -51,6 +51,11 @@ part of that first step. The first reliable win is infrastructure reuse, not dat implementation keeps the existing one-shot instrumentation helper as the fallback for startup, socket, protocol, and request failures. +Android permits only one reliable instrumentation-owned `UiAutomation` context per device. Before +starting a different instrumentation helper, such as touch synthesis, the daemon must stop the +persistent snapshot helper session and let the next snapshot restart it lazily. Helper reuse must +never turn process ownership into cross-command interference. + 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, @@ -82,7 +87,7 @@ should show meaningful wall-clock improvement on a realistic app state, not just 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. +shutdown, exclusive instrumentation handoff, 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 diff --git a/src/compat/maestro/__tests__/launch-flow.test.ts b/src/compat/maestro/__tests__/launch-flow.test.ts new file mode 100644 index 000000000..f66f9a3c1 --- /dev/null +++ b/src/compat/maestro/__tests__/launch-flow.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { SessionAction } from '../../../daemon/types.ts'; +import { parseMaestroReplayFlow } from '../replay-flow.ts'; + +test('bare launchApp uses Maestro default stop-and-relaunch semantics', () => { + const parsed = parseMaestroReplayFlow(`appId: com.pagerviewexample +--- +- launchApp +`); + + assert.deepEqual(projectAction(parsed.actions[0]), [ + 'open', + ['com.pagerviewexample'], + { relaunch: true }, + ]); +}); + +test('launchApp stopApp false opts out of relaunch', () => { + const parsed = parseMaestroReplayFlow(`appId: com.pagerviewexample +--- +- launchApp: + stopApp: false +`); + + assert.deepEqual(projectAction(parsed.actions[0]), ['open', ['com.pagerviewexample'], {}]); +}); + +function projectAction(action: SessionAction | undefined) { + if (!action) throw new Error('expected parsed action'); + return [action.command, action.positionals, action.flags]; +} diff --git a/src/compat/maestro/__tests__/swipe-flow.test.ts b/src/compat/maestro/__tests__/swipe-flow.test.ts new file mode 100644 index 000000000..2b65d43c7 --- /dev/null +++ b/src/compat/maestro/__tests__/swipe-flow.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { parseMaestroReplayFlow } from '../replay-flow.ts'; + +test('coordinate endpoints take precedence over from like Maestro', () => { + const parsed = parseMaestroReplayFlow(`appId: com.pagerviewexample +--- +- swipe: + from: + id: pager-view + start: 90%, 50% + end: 10%, 50% + duration: 100 +`); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [['__maestroSwipeScreen', ['percent', '90', '50', '10', '50', '100']]], + ); +}); + +test('coordinate swipes require both endpoints even when from is present', () => { + assert.throws( + () => + parseMaestroReplayFlow(`--- +- swipe: + from: + id: pager-view + start: 90%, 50% +`), + /both start and end coordinates/i, + ); +}); + +test('coordinate swipes reject direction like Maestro', () => { + assert.throws( + () => + parseMaestroReplayFlow(`--- +- swipe: + direction: LEFT + start: 90%, 50% + end: 10%, 50% +`), + /cannot combine direction with start\/end coordinates/i, + ); +}); diff --git a/src/compat/maestro/device-actions.ts b/src/compat/maestro/device-actions.ts index a6f2b7e4c..d5d9de66e 100644 --- a/src/compat/maestro/device-actions.ts +++ b/src/compat/maestro/device-actions.ts @@ -16,9 +16,13 @@ export function convertLaunchApp( context: MaestroParseContext, ): SessionAction { if (value === null || value === undefined) { - return action('open', [resolveMaestroString(requireAppId(config, 'launchApp'), context)]); + return action('open', [resolveMaestroString(requireAppId(config, 'launchApp'), context)], { + relaunch: true, + }); + } + if (typeof value === 'string') { + return action('open', [resolveMaestroString(value, context)], { relaunch: true }); } - if (typeof value === 'string') return action('open', [resolveMaestroString(value, context)]); if (!isPlainRecord(value)) { throw new AppError('INVALID_ARGS', 'launchApp expects a string or map.'); } @@ -39,7 +43,7 @@ export function convertLaunchApp( ); const launchArgs = readLaunchArgs(value, context); const shouldClearState = value.clearState === true; - const shouldRelaunch = !shouldClearState && (value.stopApp === true || launchArgs.length > 0); + const shouldRelaunch = !shouldClearState && value.stopApp !== false; return action('open', [appId], { ...(shouldRelaunch ? { relaunch: true } : {}), ...(shouldClearState ? { clearAppState: true } : {}), diff --git a/src/compat/maestro/interactions.ts b/src/compat/maestro/interactions.ts index e26427343..662dae633 100644 --- a/src/compat/maestro/interactions.ts +++ b/src/compat/maestro/interactions.ts @@ -188,6 +188,15 @@ export function convertSwipe(value: unknown, context: MaestroParseContext): Sess throw new AppError('INVALID_ARGS', 'swipe expects a map.'); } assertOnlyKeys(value, 'swipe', ['start', 'end', 'direction', 'duration', 'from', 'label']); + // Maestro classifies the command as coordinate-based when either endpoint key is present. + if (Object.hasOwn(value, 'start') || Object.hasOwn(value, 'end')) { + if (value.direction !== undefined) { + throw unsupportedMaestroSyntax( + 'Maestro swipe cannot combine direction with start/end coordinates.', + ); + } + return convertCoordinateSwipe(value); + } const from = value.from ?? (typeof value.label === 'string' ? value.label : undefined); if (from !== undefined) { return convertTargetedSwipe(value, from, context); @@ -230,7 +239,7 @@ function readCoordinateSwipePoints(value: Record): { if (typeof value.start === 'string' && typeof value.end === 'string') { return { start: parseMaestroPoint(value.start), end: parseMaestroPoint(value.end) }; } - throw unsupportedMaestroSyntax('Only Maestro swipe start/end coordinates are supported.'); + throw unsupportedMaestroSyntax('Maestro swipe requires both start and end coordinates.'); } function readSwipeDurationMs(duration: unknown): string | undefined { diff --git a/src/platforms/android/__tests__/android-helper-artifact.fixtures.ts b/src/platforms/android/__tests__/android-helper-artifact.fixtures.ts new file mode 100644 index 000000000..634072fbd --- /dev/null +++ b/src/platforms/android/__tests__/android-helper-artifact.fixtures.ts @@ -0,0 +1,8 @@ +import { fileURLToPath } from 'node:url'; + +export const ANDROID_HELPER_FIXTURE_APK_PATH = fileURLToPath( + new URL('./fixtures/helper-apk.fixture', import.meta.url), +); + +export const ANDROID_HELPER_FIXTURE_APK_SHA256 = + 'a5f6a2fba1163bba2f13026bd3a192f52ba2816524b7cfa83c6b7ca568f6710a'; diff --git a/src/platforms/android/__tests__/fixtures/helper-apk.fixture b/src/platforms/android/__tests__/fixtures/helper-apk.fixture new file mode 100644 index 000000000..d21a99f31 --- /dev/null +++ b/src/platforms/android/__tests__/fixtures/helper-apk.fixture @@ -0,0 +1 @@ +agent-device Android helper test fixture diff --git a/src/platforms/android/__tests__/ime-helper.test.ts b/src/platforms/android/__tests__/ime-helper.test.ts index 9f6d8cb50..8c8916095 100644 --- a/src/platforms/android/__tests__/ime-helper.test.ts +++ b/src/platforms/android/__tests__/ime-helper.test.ts @@ -115,7 +115,7 @@ test('ensureAndroidImeHelper installs with semantic provider install options', a assert.deepEqual(installCalls, [{ apkPath, replace: true, allowTestPackages: true }]); }); -test('ensureAndroidImeHelper skips install when an equal-or-newer version is already present', async () => { +test('ensureAndroidImeHelper skips install when a newer version is already present', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ime-helper-current-')); const apkPath = path.join(tmpDir, 'helper.apk'); await fs.writeFile(apkPath, 'helper-apk'); @@ -123,7 +123,7 @@ test('ensureAndroidImeHelper skips install when an equal-or-newer version is alr if (args.includes('--show-versioncode')) { return { exitCode: 0, - stdout: `package:${manifest.packageName} versionCode:${manifest.versionCode}`, + stdout: `package:${manifest.packageName} versionCode:${manifest.versionCode + 1}`, stderr: '', }; } @@ -132,7 +132,7 @@ test('ensureAndroidImeHelper skips install when an equal-or-newer version is alr const adbProvider: AndroidAdbProvider = { exec: adb, install: async () => { - throw new Error('install should not be called when the version is current'); + throw new Error('install should not be called when a newer version is present'); }, }; diff --git a/src/platforms/android/__tests__/ime-lifecycle.test.ts b/src/platforms/android/__tests__/ime-lifecycle.test.ts index e0afee591..1af7071fd 100644 --- a/src/platforms/android/__tests__/ime-lifecycle.test.ts +++ b/src/platforms/android/__tests__/ime-lifecycle.test.ts @@ -12,15 +12,16 @@ const PENDING_DIR = 'android-test-ime-pending'; // the suite passes on a fresh checkout that hasn't packaged android-ime-helper/dist (CI Coverage). vi.mock('../ime-helper.ts', async (importOriginal) => { const actual = await importOriginal(); + const fixture = await import('./android-helper-artifact.fixtures.ts'); return { ...actual, resolveAndroidImeHelperArtifact: vi.fn(async () => ({ - apkPath: '/fixture/helper.apk', + apkPath: fixture.ANDROID_HELPER_FIXTURE_APK_PATH, manifest: { name: 'android-ime-helper' as const, version: '0.0.0', assetName: 'helper.apk', - sha256: 'a'.repeat(64), + sha256: fixture.ANDROID_HELPER_FIXTURE_APK_SHA256, packageName: 'com.callstack.agentdevice.imehelper', versionCode: 1, serviceComponent: HELPER_SERVICE, diff --git a/src/platforms/android/__tests__/multitouch-helper-install.test.ts b/src/platforms/android/__tests__/multitouch-helper-install.test.ts new file mode 100644 index 000000000..4d3e98327 --- /dev/null +++ b/src/platforms/android/__tests__/multitouch-helper-install.test.ts @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { beforeEach, test } from 'vitest'; +import { + ensureAndroidMultiTouchHelper, + resetAndroidMultiTouchHelperInstallCache, +} from '../multitouch-helper.ts'; +import type { AndroidAdbExecutor, AndroidAdbProvider } from '../adb-executor.ts'; +import { ANDROID_MULTITOUCH_HELPER_MANIFEST } from './multitouch-helper.fixtures.ts'; + +beforeEach(() => { + resetAndroidMultiTouchHelperInstallCache(); +}); + +test('same-version helper is current only when installed APK bytes match', async () => { + const fixture = await makeInstallFixture('current-helper'); + const { adb, provider, pulls, installs } = makeInstalledHelperDevice({ + versionCode: fixture.artifact.manifest.versionCode, + installedApk: 'current-helper', + }); + + const result = await ensureAndroidMultiTouchHelper({ + adb, + adbProvider: provider, + artifact: fixture.artifact, + deviceKey: 'android:emulator-5554', + }); + + assert.equal(result.reason, 'current'); + assert.equal(result.installed, false); + assert.equal(result.installedSha256, fixture.artifact.manifest.sha256); + assert.equal(pulls.length, 1); + assert.equal(installs.length, 0); +}); + +test('same-version helper is replaced when installed APK bytes differ', async () => { + const fixture = await makeInstallFixture('current-helper'); + const { adb, provider, installs } = makeInstalledHelperDevice({ + versionCode: fixture.artifact.manifest.versionCode, + installedApk: 'stale-helper', + }); + + const result = await ensureAndroidMultiTouchHelper({ + adb, + adbProvider: provider, + artifact: fixture.artifact, + deviceKey: 'android:emulator-5554', + }); + + assert.equal(result.reason, 'mismatched'); + assert.equal(result.installed, true); + assert.equal(installs.length, 1); +}); + +test('same-version helper is replaced when installed APK identity is unavailable', async () => { + const fixture = await makeInstallFixture('current-helper'); + const { adb, provider, installs } = makeInstalledHelperDevice({ + versionCode: fixture.artifact.manifest.versionCode, + pullError: new Error('remote pull unavailable'), + }); + + const result = await ensureAndroidMultiTouchHelper({ + adb, + adbProvider: provider, + artifact: fixture.artifact, + deviceKey: 'android:emulator-5554', + }); + + assert.equal(result.reason, 'unverifiable'); + assert.equal(result.installed, true); + assert.equal(installs.length, 1); +}); + +test('newer installed helper remains current without an unsafe downgrade', async () => { + const fixture = await makeInstallFixture('current-helper'); + const { adb, provider, pulls, installs } = makeInstalledHelperDevice({ + versionCode: fixture.artifact.manifest.versionCode + 1, + }); + + const result = await ensureAndroidMultiTouchHelper({ + adb, + adbProvider: provider, + artifact: fixture.artifact, + deviceKey: 'android:emulator-5554', + }); + + assert.equal(result.reason, 'current'); + assert.equal(result.installed, false); + assert.equal(pulls.length, 0); + assert.equal(installs.length, 0); +}); + +test('install memo includes artifact identity, not only version code', async () => { + const first = await makeInstallFixture('first-helper'); + const second = await makeInstallFixture('second-helper'); + const device = makeInstalledHelperDevice({ + versionCode: first.artifact.manifest.versionCode, + installedApk: 'first-helper', + }); + + await ensureAndroidMultiTouchHelper({ + adb: device.adb, + adbProvider: device.provider, + artifact: first.artifact, + deviceKey: 'android:emulator-5554', + }); + device.setInstalledApk('first-helper'); + const result = await ensureAndroidMultiTouchHelper({ + adb: device.adb, + adbProvider: device.provider, + artifact: second.artifact, + deviceKey: 'android:emulator-5554', + }); + + assert.equal(result.reason, 'mismatched'); + assert.equal(result.installed, true); +}); + +test('install memo skips repeated APK reads for the same artifact identity', async () => { + const fixture = await makeInstallFixture('current-helper'); + const device = makeInstalledHelperDevice({ + versionCode: fixture.artifact.manifest.versionCode, + installedApk: 'current-helper', + }); + + await ensureAndroidMultiTouchHelper({ + adb: device.adb, + adbProvider: device.provider, + artifact: fixture.artifact, + deviceKey: 'android:emulator-5554', + }); + await fs.rm(fixture.artifact.apkPath); + const result = await ensureAndroidMultiTouchHelper({ + adb: async () => { + throw new Error('cached ensure should not access adb'); + }, + adbProvider: { + exec: async () => { + throw new Error('cached ensure should not access the provider'); + }, + }, + artifact: fixture.artifact, + deviceKey: 'android:emulator-5554', + }); + + assert.equal(result.reason, 'current'); + assert.equal(result.installed, false); +}); + +async function makeInstallFixture(apk: string) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'multitouch-helper-identity-')); + const apkPath = path.join(directory, 'helper.apk'); + await fs.writeFile(apkPath, apk); + return { + artifact: { + apkPath, + manifest: { + ...ANDROID_MULTITOUCH_HELPER_MANIFEST, + sha256: sha256Text(apk), + }, + }, + }; +} + +function makeInstalledHelperDevice(options: { + versionCode: number; + installedApk?: string; + pullError?: Error; +}) { + let installedApk = options.installedApk; + const pulls: Array<{ remotePath: string; localPath: string }> = []; + const installs: string[] = []; + const adb: AndroidAdbExecutor = async (args) => { + if (args.includes('--show-versioncode')) { + return { + exitCode: 0, + stdout: `package:${ANDROID_MULTITOUCH_HELPER_MANIFEST.packageName} versionCode:${options.versionCode}`, + stderr: '', + }; + } + if (args[0] === 'shell' && args[1] === 'pm' && args[2] === 'path') { + return { exitCode: 0, stdout: 'package:/data/app/helper/base.apk\n', stderr: '' }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + const provider: AndroidAdbProvider = { + exec: adb, + pull: async (remotePath, localPath) => { + pulls.push({ remotePath, localPath }); + if (options.pullError) throw options.pullError; + if (installedApk === undefined) throw new Error('installed APK fixture is missing'); + await fs.writeFile(localPath, installedApk); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + install: async (apkPath) => { + installs.push(apkPath); + installedApk = await fs.readFile(apkPath, 'utf8'); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }; + return { + adb, + provider, + pulls, + installs, + setInstalledApk(value: string) { + installedApk = value; + }, + }; +} + +function sha256Text(text: string): string { + return crypto.createHash('sha256').update(text).digest('hex'); +} diff --git a/src/platforms/android/__tests__/multitouch-helper-ownership.test.ts b/src/platforms/android/__tests__/multitouch-helper-ownership.test.ts new file mode 100644 index 000000000..ea550192e --- /dev/null +++ b/src/platforms/android/__tests__/multitouch-helper-ownership.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test, vi } from 'vitest'; +import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/index.ts'; +import { withAndroidAdbProvider } from '../adb-executor.ts'; +import { + resetAndroidMultiTouchHelperInstallCache, + swipeGestureAndroid, +} from '../multitouch-helper.ts'; +import { stopAndroidSnapshotHelperSessionForDevice } from '../snapshot-helper.ts'; +import { + ANDROID_MULTITOUCH_HELPER_MANIFEST, + androidMultiTouchResultRecord, +} from './multitouch-helper.fixtures.ts'; + +vi.mock('../snapshot-helper.ts', async (importOriginal) => ({ + ...(await importOriginal()), + stopAndroidSnapshotHelperSessionForDevice: vi.fn(async () => {}), +})); + +vi.mock('../helper-package-install.ts', async (importOriginal) => { + const actual = await importOriginal(); + const fixture = await import('./android-helper-artifact.fixtures.ts'); + const multitouchFixture = await import('./multitouch-helper.fixtures.ts'); + return { + ...actual, + resolveAndroidHelperArtifact: vi.fn(async () => ({ + apkPath: fixture.ANDROID_HELPER_FIXTURE_APK_PATH, + manifest: { + ...multitouchFixture.ANDROID_MULTITOUCH_HELPER_MANIFEST, + sha256: fixture.ANDROID_HELPER_FIXTURE_APK_SHA256, + }, + })), + }; +}); + +const mockStopSnapshotSession = vi.mocked(stopAndroidSnapshotHelperSessionForDevice); + +beforeEach(() => { + resetAndroidMultiTouchHelperInstallCache(); + mockStopSnapshotSession.mockReset(); +}); + +test('helper gesture releases persistent snapshot instrumentation before touch instrumentation', async () => { + const events: string[] = []; + mockStopSnapshotSession.mockImplementation(async () => { + events.push('snapshot-stop'); + }); + + const result = await withAndroidAdbProvider( + { + exec: async (args) => { + if (args.includes('--show-versioncode')) { + return { + exitCode: 0, + stdout: `package:${ANDROID_MULTITOUCH_HELPER_MANIFEST.packageName} versionCode:999999`, + stderr: '', + }; + } + if (args.includes('instrument')) { + events.push('touch-instrumentation'); + return { + exitCode: 0, + stdout: [ + androidMultiTouchResultRecord({ ok: 'true', kind: 'swipe' }), + 'INSTRUMENTATION_CODE: 0', + ].join('\n'), + stderr: '', + }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => + await swipeGestureAndroid(ANDROID_EMULATOR, { + x1: 340, + y1: 400, + x2: 60, + y2: 400, + durationMs: 300, + }), + ); + + assert.equal(result?.backend, 'android-multitouch-helper'); + assert.deepEqual(events, ['snapshot-stop', 'touch-instrumentation']); +}); diff --git a/src/platforms/android/__tests__/multitouch-helper.fixtures.ts b/src/platforms/android/__tests__/multitouch-helper.fixtures.ts new file mode 100644 index 000000000..17d438863 --- /dev/null +++ b/src/platforms/android/__tests__/multitouch-helper.fixtures.ts @@ -0,0 +1,17 @@ +export const ANDROID_MULTITOUCH_HELPER_MANIFEST = { + name: 'android-multitouch-helper' as const, + version: '0.15.0', + assetName: 'helper.apk', + sha256: 'a'.repeat(64), + packageName: 'com.callstack.agentdevice.multitouchhelper', + versionCode: 15000, + instrumentationRunner: 'com.callstack.agentdevice.multitouchhelper/.MultiTouchInstrumentation', + statusProtocol: 'android-multitouch-helper-v1' as const, +}; + +export function androidMultiTouchResultRecord(values: Record): string { + return [ + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-multitouch-helper-v1', + ...Object.entries(values).map(([key, value]) => `INSTRUMENTATION_RESULT: ${key}=${value}`), + ].join('\n'); +} diff --git a/src/platforms/android/__tests__/multitouch-helper.test.ts b/src/platforms/android/__tests__/multitouch-helper.test.ts index a6d589f4a..0b7c3dfd1 100644 --- a/src/platforms/android/__tests__/multitouch-helper.test.ts +++ b/src/platforms/android/__tests__/multitouch-helper.test.ts @@ -18,17 +18,10 @@ import { type AndroidAdbExecutor, type AndroidAdbProvider, } from '../adb-executor.ts'; - -const manifest = { - name: 'android-multitouch-helper' as const, - version: '0.15.0', - assetName: 'helper.apk', - sha256: 'a'.repeat(64), - packageName: 'com.callstack.agentdevice.multitouchhelper', - versionCode: 15000, - instrumentationRunner: 'com.callstack.agentdevice.multitouchhelper/.MultiTouchInstrumentation', - statusProtocol: 'android-multitouch-helper-v1' as const, -}; +import { + ANDROID_MULTITOUCH_HELPER_MANIFEST as manifest, + androidMultiTouchResultRecord as resultRecord, +} from './multitouch-helper.fixtures.ts'; beforeEach(() => { resetAndroidMultiTouchHelperInstallCache(); @@ -300,13 +293,6 @@ test('ensureAndroidMultiTouchHelper installs with semantic provider install opti assert.deepEqual(installCalls, [{ apkPath, replace: true, allowTestPackages: true }]); }); -function resultRecord(values: Record): string { - return [ - 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-multitouch-helper-v1', - ...Object.entries(values).map(([key, value]) => `INSTRUMENTATION_RESULT: ${key}=${value}`), - ].join('\n'); -} - function sha256Text(text: string): string { return crypto.createHash('sha256').update(text).digest('hex'); } diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts index 6ec809bcc..e3c7ce9e3 100644 --- a/src/platforms/android/__tests__/snapshot-helper.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper.test.ts @@ -13,10 +13,8 @@ import { forgetAndroidSnapshotHelperInstall, resetAndroidSnapshotHelperInstallCache, } from '../snapshot-helper-install.ts'; -import { - parseAndroidSnapshotHelperManifest, - verifyAndroidSnapshotHelperArtifact, -} from '../snapshot-helper-artifact.ts'; +import { parseAndroidSnapshotHelperManifest } from '../snapshot-helper-artifact.ts'; +import { verifyAndroidHelperApkChecksum } from '../helper-package-install.ts'; import type { AndroidAdbExecutor, AndroidSnapshotHelperManifest, @@ -167,7 +165,7 @@ test('parseAndroidSnapshotHelperOutput falls back to error type for null helper }); }); -test('ensureAndroidSnapshotHelper installs when missing and skips current version', async () => { +test('ensureAndroidSnapshotHelper installs when missing and skips a newer version', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-')); const apkPath = path.join(tmpDir, 'helper.apk'); await fs.writeFile(apkPath, 'helper-apk'); @@ -196,16 +194,61 @@ test('ensureAndroidSnapshotHelper installs when missing and skips current versio const skipped = await ensureAndroidSnapshotHelper({ adb: async () => ({ exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', stderr: '', }), - artifact: { apkPath: '/tmp/helper.apk', manifest }, + artifact: { apkPath, manifest: localManifest }, }); assert.equal(skipped.installed, false); assert.equal(skipped.reason, 'current'); }); +test('ensureAndroidSnapshotHelper replaces same-version helper when APK bytes differ', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-identity-')); + const apkPath = path.join(tmpDir, 'helper.apk'); + await fs.writeFile(apkPath, 'new-helper-apk'); + const localManifest = { + ...manifest, + sha256: sha256Text('new-helper-apk'), + }; + const installs: string[] = []; + const adb: AndroidAdbExecutor = async (args) => { + if (args.includes('--show-versioncode')) { + return { + exitCode: 0, + stdout: `package:${localManifest.packageName} versionCode:${localManifest.versionCode}`, + stderr: '', + }; + } + if (args[0] === 'shell' && args[1] === 'pm' && args[2] === 'path') { + return { exitCode: 0, stdout: 'package:/data/app/helper/base.apk\n', stderr: '' }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + + const result = await ensureAndroidSnapshotHelper({ + adb, + adbProvider: { + exec: adb, + pull: async (_remotePath, localPath) => { + await fs.writeFile(localPath, 'old-helper-apk'); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + install: async (pathToInstall) => { + installs.push(pathToInstall); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }, + artifact: { apkPath, manifest: localManifest }, + deviceKey: 'android:emulator-5554', + }); + + assert.equal(result.reason, 'mismatched'); + assert.equal(result.installed, true); + assert.deepEqual(installs, [apkPath]); +}); + test('ensureAndroidSnapshotHelper caches successful install checks per device and helper version', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-cache-')); const apkPath = path.join(tmpDir, 'helper.apk'); @@ -229,6 +272,7 @@ test('ensureAndroidSnapshotHelper caches successful install checks per device an artifact, deviceKey: 'android:emulator-5554', }); + await fs.rm(apkPath); const cached = await ensureAndroidSnapshotHelper({ adb, artifact, @@ -252,6 +296,7 @@ test('ensureAndroidSnapshotHelper caches successful install checks per device an ['install', '-r', '-t', apkPath], ]); + await fs.writeFile(apkPath, 'helper-apk'); await ensureAndroidSnapshotHelper({ adb, artifact, @@ -286,7 +331,7 @@ test('ensureAndroidSnapshotHelper always policy bypasses cached install result', if (args.includes('--show-versioncode')) { return { exitCode: 0, - stdout: `package:${localManifest.packageName} versionCode:${localManifest.versionCode}`, + stdout: `package:${localManifest.packageName} versionCode:${localManifest.versionCode + 1}`, stderr: '', }; } @@ -332,17 +377,14 @@ test('ensureAndroidSnapshotHelper always policy bypasses cached install result', ]); }); -test('verifyAndroidSnapshotHelperArtifact rejects checksum mismatch', async () => { +test('shared Android helper verifier rejects snapshot helper checksum mismatch', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-sha-')); const apkPath = path.join(tmpDir, 'helper.apk'); await fs.writeFile(apkPath, 'actual'); await assert.rejects( () => - verifyAndroidSnapshotHelperArtifact({ - apkPath, - manifest: { ...manifest, sha256: sha256Text('expected') }, - }), + verifyAndroidHelperApkChecksum(apkPath, sha256Text('expected'), 'Android snapshot helper'), { message: 'Android snapshot helper APK checksum mismatch' }, ); }); diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index 81c3bd757..ace0e45d6 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -32,6 +32,10 @@ import { type AndroidAdbProcess, type AndroidAdbProvider, } from '../adb-executor.ts'; +import { + ANDROID_HELPER_FIXTURE_APK_PATH, + ANDROID_HELPER_FIXTURE_APK_SHA256, +} from './android-helper-artifact.fixtures.ts'; const VALID_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+b9xkAAAAASUVORK5CYII=', @@ -53,7 +57,7 @@ const helperManifest: AndroidSnapshotHelperManifest = { name: 'android-snapshot-helper', version: '0.13.3', apkUrl: null, - sha256: 'a'.repeat(64), + sha256: ANDROID_HELPER_FIXTURE_APK_SHA256, packageName: 'com.callstack.agentdevice.snapshothelper', versionCode: 13003, instrumentationRunner: 'com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation', @@ -65,12 +69,12 @@ const helperManifest: AndroidSnapshotHelperManifest = { }; const helperArtifact = { - apkPath: '/tmp/helper.apk', + apkPath: ANDROID_HELPER_FIXTURE_APK_PATH, manifest: helperManifest, }; const installedHelperProbe = { exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', stderr: '', }; @@ -514,7 +518,7 @@ test('snapshotAndroid uses injected helper artifact before stock uiautomator', a if (args.includes('--show-versioncode')) { return { exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', stderr: '', }; } @@ -539,7 +543,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, [5000, 30000]); assert.equal(mockRunCmd.mock.calls.length, 0); }); @@ -605,7 +609,7 @@ test('snapshotAndroid emits helper phase diagnostics', async () => { if (args.includes('--show-versioncode')) { return { exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', stderr: '', }; } @@ -646,7 +650,7 @@ test('snapshotAndroid resolves helper adb through scoped provider', async () => if (args.includes('--show-versioncode')) { return { exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', stderr: '', }; } @@ -759,7 +763,7 @@ test('snapshotAndroid falls back to stock uiautomator when helper fails', async if (args.includes('--show-versioncode')) { return { exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', stderr: '', }; } @@ -967,7 +971,7 @@ test('snapshotAndroid emits fallback and stock capture diagnostics', async () => if (args.includes('--show-versioncode')) { return { exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', stderr: '', }; } @@ -1184,7 +1188,7 @@ test('snapshotAndroid preserves helper failure reason when stock fallback fails' if (args.includes('--show-versioncode')) { return { exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', stderr: '', }; } @@ -1223,7 +1227,7 @@ test('snapshotAndroid re-probes helper install after helper capture failure', as versionProbeCount += 1; return { exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13004', stderr: '', }; } diff --git a/src/platforms/android/__tests__/ui-hierarchy.test.ts b/src/platforms/android/__tests__/ui-hierarchy.test.ts index 4fb891227..2dfe4bd85 100644 --- a/src/platforms/android/__tests__/ui-hierarchy.test.ts +++ b/src/platforms/android/__tests__/ui-hierarchy.test.ts @@ -275,6 +275,27 @@ test('parseUiHierarchy prunes lower drawing-order subtrees covered by a foregrou ); }); +test('parseUiHierarchy keeps visible identifier-only markers beside covering content', () => { + const xml = ` + + + + + + +`; + + const result = parseUiHierarchy(xml, 800, { raw: true }); + assert.equal( + result.nodes.some((node) => node.identifier === 'post-auth-screen'), + true, + ); + assert.equal( + result.nodes.some((node) => node.label === 'Tab 1'), + true, + ); +}); + test('parseUiHierarchy keeps visible side-by-side drawer and content subtrees', () => { const xml = ` diff --git a/src/platforms/android/helper-package-install.ts b/src/platforms/android/helper-package-install.ts index e67ddef9c..86fac2375 100644 --- a/src/platforms/android/helper-package-install.ts +++ b/src/platforms/android/helper-package-install.ts @@ -1,11 +1,13 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import { AppError, normalizeError } from '../../kernel/errors.ts'; import { findProjectRoot, readVersion } from '../../utils/version.ts'; import { androidAdbResultError, installAndroidAdbPackage, + pullAndroidAdbFile, type AndroidAdbExecutor, type AndroidAdbProvider, } from './adb-executor.ts'; @@ -16,10 +18,18 @@ export type AndroidHelperInstallDecision = { packageName: string; versionCode: number; installedVersionCode?: number; + installedSha256?: string; installed: boolean; - reason: 'missing' | 'outdated' | 'current'; + reason: 'missing' | 'outdated' | 'mismatched' | 'unverifiable' | 'current'; }; +export type InstalledAndroidHelperState = Pick< + AndroidHelperInstallDecision, + 'installedVersionCode' | 'installedSha256' | 'reason' +>; + +const ANDROID_HELPER_IDENTITY_TIMEOUT_MS = 10_000; + async function ensureAndroidHelperPackageInstalled(options: { adb: AndroidAdbExecutor; adbProvider: AndroidAdbProvider; @@ -44,16 +54,35 @@ async function ensureAndroidHelperPackageInstalled(options: { installTimeoutMs, helperLabel, } = options; - const cacheKey = `${deviceKey}\0${packageName}\0${versionCode}`; + const cacheKey = `${deviceKey}\0${packageName}\0${versionCode}\0${sha256}`; if (cache.has(cacheKey)) { - return { packageName, versionCode, installed: false, reason: 'current' }; + return { + packageName, + versionCode, + installedSha256: sha256, + installed: false, + reason: 'current', + }; } - const installedVersionCode = await readInstalledAndroidPackageVersionCode(adb, packageName); - if (installedVersionCode !== undefined && installedVersionCode >= versionCode) { + await verifyAndroidHelperApkChecksum(apkPath, sha256, helperLabel); + const state = await inspectInstalledAndroidHelper({ + adb, + adbProvider, + packageName, + versionCode, + sha256, + }); + + if (state.reason === 'current') { cache.add(cacheKey); - return { packageName, versionCode, installedVersionCode, installed: false, reason: 'current' }; + return { + packageName, + versionCode, + ...state, + installed: false, + }; } - await verifyAndroidHelperApkChecksum(apkPath, sha256, helperLabel); + const result = await installAndroidAdbPackage(apkPath, { provider: adbProvider, replace: true, @@ -65,18 +94,43 @@ async function ensureAndroidHelperPackageInstalled(options: { throw androidAdbResultError(`Failed to install ${helperLabel}`, result, { packageName, versionCode, + reason: state.reason, }); } cache.add(cacheKey); return { packageName, versionCode, - installedVersionCode, + ...state, installed: true, - reason: installedVersionCode === undefined ? 'missing' : 'outdated', }; } +export async function inspectInstalledAndroidHelper(options: { + adb: AndroidAdbExecutor; + adbProvider: AndroidAdbProvider; + packageName: string; + versionCode: number; + sha256: string; +}): Promise { + const { adb, adbProvider, packageName, versionCode, sha256 } = options; + const installedVersionCode = await readInstalledAndroidPackageVersionCode(adb, packageName); + if (installedVersionCode === undefined) return { reason: 'missing' }; + if (installedVersionCode < versionCode) return { installedVersionCode, reason: 'outdated' }; + if (installedVersionCode > versionCode) return { installedVersionCode, reason: 'current' }; + + try { + const installedSha256 = await readInstalledAndroidPackageSha256(adb, adbProvider, packageName); + return { + installedVersionCode, + installedSha256, + reason: installedSha256 === sha256 ? 'current' : 'mismatched', + }; + } catch { + return { installedVersionCode, reason: 'unverifiable' }; + } +} + // Resolves an npm-bundled helper artifact: parses/validates its manifest, confirms the APK exists. export async function resolveAndroidHelperArtifact< Manifest extends { assetName: string }, @@ -151,7 +205,48 @@ async function readInstalledAndroidPackageVersionCode( return match ? Number(match[1]) : undefined; } -async function verifyAndroidHelperApkChecksum( +async function readInstalledAndroidPackageSha256( + adb: AndroidAdbExecutor, + adbProvider: AndroidAdbProvider, + packageName: string, +): Promise { + const pathResult = await adb(['shell', 'pm', 'path', packageName], { + allowFailure: true, + timeoutMs: ANDROID_HELPER_IDENTITY_TIMEOUT_MS, + }); + const remotePath = readBaseApkPath(`${pathResult.stdout}\n${pathResult.stderr}`); + if (pathResult.exitCode !== 0 || !remotePath) { + throw new AppError('COMMAND_FAILED', 'Could not resolve installed Android helper APK'); + } + + const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-helper-')); + const localPath = path.join(tempDirectory, 'base.apk'); + try { + const pull = await pullAndroidAdbFile(remotePath, localPath, { + provider: adbProvider, + allowFailure: true, + timeoutMs: ANDROID_HELPER_IDENTITY_TIMEOUT_MS, + }); + if (pull.exitCode !== 0) { + throw androidAdbResultError('Could not read installed Android helper APK', pull, { + packageName, + remotePath, + }); + } + return await sha256File(localPath); + } finally { + await fs.rm(tempDirectory, { recursive: true, force: true }); + } +} + +function readBaseApkPath(output: string): string | undefined { + return output + .split(/\r?\n/) + .map((line) => (line.startsWith('package:') ? line.slice('package:'.length).trim() : '')) + .find((candidate) => candidate.endsWith('/base.apk')); +} + +export async function verifyAndroidHelperApkChecksum( apkPath: string, expectedSha256: string, helperLabel: string, diff --git a/src/platforms/android/multitouch-helper.ts b/src/platforms/android/multitouch-helper.ts index 64c2bda3a..812cb8cfe 100644 --- a/src/platforms/android/multitouch-helper.ts +++ b/src/platforms/android/multitouch-helper.ts @@ -11,6 +11,7 @@ import { type AndroidTouchGestureRequest, } from './adb-executor.ts'; import { getAndroidScreenSize, swipeAndroid } from './input-actions.ts'; +import { stopAndroidSnapshotHelperSessionForDevice } from './snapshot-helper.ts'; import { parseInstrumentationRecords, readAndroidHelperManifestInteger, @@ -265,6 +266,7 @@ async function runAndroidMultiTouchHelperGestureForDevice( phase: 'android_multitouch_helper_install_decision', data: install, }); + await stopAndroidSnapshotHelperSessionForDevice(device); const output = await withDiagnosticTimer( 'android_multitouch_helper_gesture', async () => diff --git a/src/platforms/android/snapshot-helper-artifact.ts b/src/platforms/android/snapshot-helper-artifact.ts index 650fecfcf..27de6c444 100644 --- a/src/platforms/android/snapshot-helper-artifact.ts +++ b/src/platforms/android/snapshot-helper-artifact.ts @@ -1,5 +1,3 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; import { AppError } from '../../kernel/errors.ts'; import { readAndroidHelperManifestInteger, @@ -9,7 +7,6 @@ import { ANDROID_SNAPSHOT_HELPER_NAME, ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, ANDROID_SNAPSHOT_HELPER_PROTOCOL, - type AndroidSnapshotHelperArtifact, type AndroidSnapshotHelperManifest, } from './snapshot-helper-types.ts'; @@ -31,19 +28,6 @@ const ANDROID_SNAPSHOT_HELPER_INSTALL_FLAG_OPTIONS = { type AndroidSnapshotHelperInstallFlag = keyof typeof ANDROID_SNAPSHOT_HELPER_INSTALL_FLAG_OPTIONS; -export async function verifyAndroidSnapshotHelperArtifact( - artifact: AndroidSnapshotHelperArtifact, -): Promise { - const actual = await sha256File(artifact.apkPath); - if (actual !== artifact.manifest.sha256) { - throw new AppError('COMMAND_FAILED', 'Android snapshot helper APK checksum mismatch', { - apkPath: artifact.apkPath, - expectedSha256: artifact.manifest.sha256, - actualSha256: actual, - }); - } -} - export function parseAndroidSnapshotHelperManifest(value: unknown): AndroidSnapshotHelperManifest { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new AppError('INVALID_ARGS', 'Android snapshot helper manifest must be an object.'); @@ -148,16 +132,6 @@ function readSha256(value: unknown): string { return sha256; } -async function sha256File(filePath: string): Promise { - return await new Promise((resolve, reject) => { - const hash = crypto.createHash('sha256'); - const stream = fs.createReadStream(filePath); - stream.on('error', reject); - stream.on('data', (chunk) => hash.update(chunk)); - stream.on('end', () => resolve(hash.digest('hex'))); - }); -} - function readString(value: unknown, field: string): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new AppError('INVALID_ARGS', `Android snapshot helper manifest ${field} is required.`); diff --git a/src/platforms/android/snapshot-helper-install.ts b/src/platforms/android/snapshot-helper-install.ts index ad5d6aeb6..823b4de9a 100644 --- a/src/platforms/android/snapshot-helper-install.ts +++ b/src/platforms/android/snapshot-helper-install.ts @@ -1,7 +1,8 @@ +import { readAndroidSnapshotHelperInstallOptions } from './snapshot-helper-artifact.ts'; import { - readAndroidSnapshotHelperInstallOptions, - verifyAndroidSnapshotHelperArtifact, -} from './snapshot-helper-artifact.ts'; + inspectInstalledAndroidHelper, + verifyAndroidHelperApkChecksum, +} from './helper-package-install.ts'; import { androidAdbResultError, installAndroidAdbPackage, @@ -22,9 +23,10 @@ export function forgetAndroidSnapshotHelperInstall(options: { packageName: string; versionCode: number; }): void { - forgetInstalledSnapshotHelper( - getInstallCacheKey(options.deviceKey, options.packageName, options.versionCode), - ); + const prefix = `${options.deviceKey}\0${options.packageName}\0${options.versionCode}\0`; + for (const cacheKey of installedSnapshotHelpers.keys()) { + if (cacheKey.startsWith(prefix)) forgetInstalledSnapshotHelper(cacheKey); + } } // Tests reset the process-global install memo so cases do not share helper state. @@ -36,8 +38,9 @@ function getInstallCacheKey( deviceKey: string | undefined, packageName: string, versionCode: number, + sha256: string, ): string | undefined { - return deviceKey ? `${deviceKey}\0${packageName}\0${versionCode}` : undefined; + return deviceKey ? `${deviceKey}\0${packageName}\0${versionCode}\0${sha256}` : undefined; } function rememberInstalledSnapshotHelper( @@ -67,6 +70,7 @@ export async function ensureAndroidSnapshotHelper(options: { const installPolicy = options.installPolicy ?? 'missing-or-outdated'; const packageName = artifact.manifest.packageName; const versionCode = artifact.manifest.versionCode; + const sha256 = artifact.manifest.sha256; if (installPolicy === 'never') { return { packageName, @@ -75,7 +79,7 @@ export async function ensureAndroidSnapshotHelper(options: { reason: 'skipped', }; } - const installCacheKey = getInstallCacheKey(options.deviceKey, packageName, versionCode); + const installCacheKey = getInstallCacheKey(options.deviceKey, packageName, versionCode, sha256); const cachedVersionCode = installCacheKey ? installedSnapshotHelpers.get(installCacheKey) : undefined; @@ -84,12 +88,29 @@ export async function ensureAndroidSnapshotHelper(options: { packageName, versionCode, installedVersionCode: cachedVersionCode, + installedSha256: sha256, installed: false, reason: 'current', }; } - const installedVersionCode = await readInstalledVersionCode(adb, packageName, options.timeoutMs); - const reason = getInstallReason(installPolicy, installedVersionCode, versionCode); + await verifyAndroidHelperApkChecksum(artifact.apkPath, sha256, 'Android snapshot helper'); + const installedState = + installPolicy === 'always' + ? { + installedVersionCode: await readInstalledVersionCode(adb, packageName, options.timeoutMs), + reason: 'forced' as const, + } + : await inspectInstalledAndroidHelper({ + adb, + adbProvider: normalizeAdbProvider(options.adbProvider, adb), + packageName, + versionCode, + sha256, + }); + const { installedVersionCode } = installedState; + const installedSha256 = + 'installedSha256' in installedState ? installedState.installedSha256 : undefined; + const reason = installedState.reason; if (reason === 'current') { if (installedVersionCode === undefined) { @@ -100,12 +121,12 @@ export async function ensureAndroidSnapshotHelper(options: { packageName, versionCode, installedVersionCode, + installedSha256, installed: false, reason, }; } - await verifyAndroidSnapshotHelperArtifact(artifact); const result = await installAndroidSnapshotHelper( adb, options.adbProvider ?? adb, @@ -129,11 +150,20 @@ export async function ensureAndroidSnapshotHelper(options: { packageName, versionCode, installedVersionCode, + installedSha256, installed: true, reason, }; } +function normalizeAdbProvider( + provider: AndroidAdbProvider | AndroidAdbExecutor | undefined, + adb: AndroidAdbExecutor, +): AndroidAdbProvider { + if (!provider) return { exec: adb }; + return typeof provider === 'function' ? { exec: provider } : provider; +} + async function readInstalledVersionCode( adb: AndroidAdbExecutor, packageName: string, @@ -214,20 +244,3 @@ function parsePackageListVersionCode(output: string, packageName: string): numbe function isInstallUpdateIncompatible(result: { stdout: string; stderr: string }): boolean { return `${result.stdout}\n${result.stderr}`.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE'); } - -function getInstallReason( - installPolicy: AndroidSnapshotHelperInstallPolicy, - installedVersionCode: number | undefined, - requiredVersionCode: number, -): AndroidSnapshotHelperInstallResult['reason'] { - if (installPolicy === 'never') { - return 'skipped'; - } - if (installPolicy === 'always') { - return 'forced'; - } - if (installedVersionCode === undefined) { - return 'missing'; - } - return installedVersionCode < requiredVersionCode ? 'outdated' : 'current'; -} diff --git a/src/platforms/android/snapshot-helper-types.ts b/src/platforms/android/snapshot-helper-types.ts index 71f05dad9..ef52aebb6 100644 --- a/src/platforms/android/snapshot-helper-types.ts +++ b/src/platforms/android/snapshot-helper-types.ts @@ -6,6 +6,8 @@ export type AndroidSnapshotCaptureMode = 'interactive-windows' | 'active-window' export type AndroidSnapshotHelperInstallReason = | 'missing' | 'outdated' + | 'mismatched' + | 'unverifiable' | 'forced' | 'current' | 'skipped'; @@ -51,6 +53,7 @@ export type AndroidSnapshotHelperInstallResult = { packageName: string; versionCode: number; installedVersionCode?: number; + installedSha256?: string; installed: boolean; reason: AndroidSnapshotHelperInstallReason; }; diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 0dda4069f..b7d33bf77 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -294,6 +294,8 @@ async function installAndroidSnapshotHelper( packageName: install.packageName, versionCode: install.versionCode, installedVersionCode: install.installedVersionCode, + artifactSha256: artifact.manifest.sha256, + installedSha256: install.installedSha256, installed: install.installed, reason: install.reason, }, diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index f2ed1b8ed..a9d44eea2 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -549,11 +549,24 @@ function pruneAndroidCoveredSubtrees(node: AndroidNode, state: AndroidTreePruneS const siblings = node.children; const coveringCandidates = siblings.filter((sibling) => canCoverSibling(sibling, state)); if (coveringCandidates.length === 0) return; - node.children = siblings.filter( - (child) => !isCoveredByHigherDrawingOrderSibling(child, coveringCandidates), + node.children = siblings.filter((child) => shouldKeepAndroidSibling(child, coveringCandidates)); +} + +function shouldKeepAndroidSibling( + node: AndroidNode, + coveringCandidates: AndroidCoveringCandidate[], +): boolean { + return ( + isSemanticIdentifierMarker(node) || + !isCoveredByHigherDrawingOrderSibling(node, coveringCandidates) ); } +function isSemanticIdentifierMarker(node: AndroidNode): boolean { + // RN can emit a screen-level testID as an empty sibling beside its rendered navigator. + return hasMeaningfulIdentifier(node) && !node.hittable && node.children.length === 0; +} + function isCoveredByHigherDrawingOrderSibling( node: AndroidNode, coveringCandidates: AndroidCoveringCandidate[], @@ -561,7 +574,6 @@ function isCoveredByHigherDrawingOrderSibling( if (node.visibleToUser === false || node.drawingOrder === undefined || !hasPositiveRect(node)) { return false; } - for (const sibling of coveringCandidates) { if (sibling === node || sibling.drawingOrder <= node.drawingOrder) { continue; @@ -573,6 +585,11 @@ function isCoveredByHigherDrawingOrderSibling( return false; } +function hasMeaningfulIdentifier(node: AndroidNode): boolean { + const identifier = node.identifier?.trim() ?? ''; + return Boolean(identifier && !isGenericAndroidId(identifier)); +} + function canCoverSibling( node: AndroidNode, state: AndroidTreePruneState, diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index b593c5122..e71ab7826 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -149,7 +149,7 @@ test('Provider-backed integration Android Maestro replay test suite discovers YA ); fs.writeFileSync( path.join(suiteRoot, '02-tap.yml'), - ['appId: com.android.settings', '---', '- tapOn: Search', ''].join('\n'), + ['appId: com.android.settings', '---', '- launchApp', '- tapOn: Search', ''].join('\n'), ); const suite = await client.replay.test({ @@ -168,6 +168,11 @@ test('Provider-backed integration Android Maestro replay test suite discovers YA world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), ['shell', 'input', 'tap', '180', '330'], ); + assert.equal( + world.adbCalls.filter((call) => call.slice(0, 3).join(' ') === 'shell am force-stop') + .length, + 2, + ); assertSnapshotCountInRange(snapshots, 2, 3); }, );