diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ca7225be..3b3ae3024b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ opening the request log. Long waits keep the first five and last twenty-five polls. The replay landmark-mismatch refusal carries the same poll evidence next to its mismatch details; `wait --stable` timeouts and a never-readable strict absence keep their existing diagnostics. +- Fixed: Android `orientation` now returns once the display reports the requested rotation + (polling `dumpsys display`, up to 15s) instead of right after writing the settings. On a loaded + emulator the rotation takes seconds, during which accessibility reads hang, so the next command + paid for the transition; a `wait` issued right after `orientation` could spend its whole budget + there. A display that never reaches the requested rotation now fails the command with the + observed rotation instead of reporting success; a display that reports no rotation is left to + the setting as before. - Fixed: the iOS Simulator AX snapshot route bounds how long a capture waits for app discovery and stops starting a discovery per capture. Discovery (`simctl launchctl list` through xcrun) takes seconds on a loaded host; a capture now waits at most 1.5s for the one in-flight diff --git a/packages/platform-android/src/__tests__/input-actions.test.ts b/packages/platform-android/src/__tests__/input-actions.test.ts index 45abd28ddf..a6f55b214a 100644 --- a/packages/platform-android/src/__tests__/input-actions.test.ts +++ b/packages/platform-android/src/__tests__/input-actions.test.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { backAndroid, @@ -175,15 +175,114 @@ test('pressAndroidEnter presses the ENTER keyevent', async () => { ); }); -test('setAndroidOrientation locks auto-rotate and sets user rotation', async () => { +// The orientation settle polls at its own interval; the clock is the assertion, not the wait. +vi.mock('@agent-device/host-kit/retry', () => ({ sleep: async () => {} })); + +const ORIENTATION_CALLS = [ + ['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'], + ['shell', 'settings', 'put', 'system', 'user_rotation', '1'], +]; +const DISPLAY_READ = ['shell', 'dumpsys', 'display']; + +function displayReporting(rotations: string[]): (args: string[]) => string | undefined { + let reads = 0; + return (args) => { + if (args[1] !== 'dumpsys') return undefined; + const rotation = rotations[Math.min(reads, rotations.length - 1)]; + reads += 1; + return rotation === undefined ? '' : ` mCurrentOrientation=${rotation}\n`; + }; +} + +test('setAndroidOrientation locks auto-rotate, sets user rotation, and returns once the display rotated', async () => { + await withFakeAdb(displayReporting(['0', '0', '1']), async ({ calls, device }) => { + await setAndroidOrientation(device, 'landscape-left'); + assert.deepEqual(calls, [...ORIENTATION_CALLS, DISPLAY_READ, DISPLAY_READ, DISPLAY_READ]); + }); +}); + +test('setAndroidOrientation fails when the display never reports the requested rotation', async () => { + vi.useFakeTimers({ now: 0, toFake: ['Date'] }); + const probeBudgets: number[] = []; + try { + await withFakeAdb( + (args, options) => { + // Every display read costs wall clock; the display stays where it was. + if (args[1] === 'dumpsys') { + probeBudgets.push(options?.timeoutMs ?? -1); + vi.setSystemTime(Date.now() + 4_000); + } + return displayReporting(['0'])(args); + }, + async ({ calls, device }) => { + await assert.rejects(setAndroidOrientation(device, 'landscape-left'), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /orientation landscape-left did not take effect/); + const details = (error as { details?: Record }).details ?? {}; + assert.equal(details.requestedRotation, 1); + assert.equal(details.observedRotation, 0); + return true; + }); + assert.ok(calls.filter((call) => call[1] === 'dumpsys').length >= 4); + // Each probe may use only what is left of the 15s settle budget. + assert.equal(probeBudgets[0], 15_000); + for (let index = 1; index < probeBudgets.length; index += 1) { + assert.ok(probeBudgets[index]! > 0 && probeBudgets[index]! < probeBudgets[index - 1]!); + } + }, + ); + } finally { + vi.useRealTimers(); + } +}); + +test('a display probe that hangs for the whole budget ends the settle as a failure', async () => { + vi.useFakeTimers({ now: 0, toFake: ['Date'] }); + try { + await withFakeAdb( + (args, options) => { + if (args[1] !== 'dumpsys') return undefined; + // The probe blocks until its own timeout, which is the whole remaining budget. + vi.setSystemTime(Date.now() + (options?.timeoutMs ?? 0)); + return new Error(`adb shell dumpsys display timed out after ${options?.timeoutMs}ms`); + }, + async ({ calls, device }) => { + await assert.rejects( + setAndroidOrientation(device, 'landscape-left'), + /orientation landscape-left could not confirm the display rotation: adb shell dumpsys display timed out after 15000ms/, + ); + assert.equal(calls.filter((call) => call[1] === 'dumpsys').length, 1); + assert.equal(Date.now(), 15_000); + }, + ); + } finally { + vi.useRealTimers(); + } +}); + +test('a display probe that exits non-zero fails the settle instead of passing as no field', async () => { await withFakeAdb( - () => undefined, + (args) => + args[1] === 'dumpsys' + ? { stdout: '', stderr: 'dumpsys: permission denied', exitCode: 1 } + : undefined, async ({ calls, device }) => { - await setAndroidOrientation(device, 'landscape-left'); - assert.deepEqual(calls, [ - ['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'], - ['shell', 'settings', 'put', 'system', 'user_rotation', '1'], - ]); + await assert.rejects( + setAndroidOrientation(device, 'landscape-left'), + /orientation landscape-left could not confirm the display rotation: .*exited with code 1/, + ); + assert.equal(calls.filter((call) => call[1] === 'dumpsys').length, 1); }, ); }); + +test('setAndroidOrientation leaves a display that reports no rotation to the setting', async () => { + await withFakeAdb(displayReporting([]), async ({ calls, device }) => { + await setAndroidOrientation(device, 'portrait'); + assert.deepEqual(calls, [ + ['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'], + ['shell', 'settings', 'put', 'system', 'user_rotation', '0'], + DISPLAY_READ, + ]); + }); +}); diff --git a/packages/platform-android/src/__tests__/test-utils/fake-adb.ts b/packages/platform-android/src/__tests__/test-utils/fake-adb.ts index 4d24a4ac23..1c89a3461a 100644 --- a/packages/platform-android/src/__tests__/test-utils/fake-adb.ts +++ b/packages/platform-android/src/__tests__/test-utils/fake-adb.ts @@ -10,7 +10,10 @@ import { ANDROID_EMULATOR } from './device-fixtures.ts'; import { bindAndroidAdbTestHost } from './android-host-test-setup.ts'; export type FakeAdbResponse = string | Partial | Error; -export type FakeAdbScript = (args: string[]) => FakeAdbResponse | undefined; +export type FakeAdbScript = ( + args: string[], + options?: AndroidAdbExecutorOptions, +) => FakeAdbResponse | undefined; export type FakeAdbProviderExtras = AndroidAdbProvider extends infer P ? P extends AndroidAdbProvider @@ -34,7 +37,7 @@ export async function withFakeAdb( execOptions?: AndroidAdbExecutorOptions, ): Promise => { calls.push([...args]); - const response = script(args); + const response = script(args, execOptions); if (response instanceof Error) throw response; const result: AndroidAdbExecutorResult = typeof response === 'string' diff --git a/packages/platform-android/src/input-actions.ts b/packages/platform-android/src/input-actions.ts index 5b1822c18a..607122ed4a 100644 --- a/packages/platform-android/src/input-actions.ts +++ b/packages/platform-android/src/input-actions.ts @@ -13,6 +13,7 @@ import { import { type TvRemoteButton, toAndroidTvRemoteKeyevent } from '@agent-device/contracts/tv-remote'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { sleep } from '@agent-device/host-kit/retry'; import { runAndroidAdb } from './adb.ts'; import { executeAndroidTouchPlan, readAndroidGestureViewport } from './touch-executor.ts'; import type { AndroidHelperSessionOptions } from './snapshot-helper-types.ts'; @@ -64,6 +65,68 @@ export async function setAndroidOrientation( 'user_rotation', userRotation, ]); + await settleAndroidOrientation(device, orientation, userRotation); +} + +const ORIENTATION_SETTLE_TIMEOUT_MS = 15_000; +const ORIENTATION_SETTLE_POLL_MS = 500; + +/** + * The display rotates some time after the setting lands; on a loaded emulator that takes + * seconds, during which accessibility reads hang. Returning once the display reports the + * requested rotation keeps the next command from paying for the transition. A display that never + * gets there is a fact the caller must see (a foreground app pinning its orientation, a device + * ignoring `user_rotation`); one that reports no rotation at all cannot be checked and is left to + * the setting. + */ +async function settleAndroidOrientation( + device: DeviceInfo, + orientation: DeviceRotation, + userRotation: string, +): Promise { + const deadline = Date.now() + ORIENTATION_SETTLE_TIMEOUT_MS; + let observed = await readAndroidDisplayRotation(device, orientation, deadline); + while (observed !== undefined && observed !== userRotation && Date.now() < deadline) { + await sleep(Math.min(ORIENTATION_SETTLE_POLL_MS, remainingMs(deadline))); + observed = await readAndroidDisplayRotation(device, orientation, deadline); + } + if (observed === undefined || observed === userRotation) return; + throw new AppError( + 'COMMAND_FAILED', + `orientation ${orientation} did not take effect: the display still reports rotation ${observed} after ${ORIENTATION_SETTLE_TIMEOUT_MS}ms`, + { + requestedRotation: Number(userRotation), + observedRotation: Number(observed), + hint: 'The foreground app may pin its orientation, or the device may ignore user_rotation. Check `adb shell dumpsys display | grep mCurrentOrientation` and the app manifest.', + }, + ); +} + +/** + * One display read, bounded by what is left of the settle budget so a stuck probe ends the + * settle. A probe that fails (non-zero exit, timeout) is a failed settle, never "no field". + */ +async function readAndroidDisplayRotation( + device: DeviceInfo, + orientation: DeviceRotation, + deadline: number, +): Promise { + try { + const result = await runAndroidAdb(device, ['shell', 'dumpsys', 'display'], { + timeoutMs: remainingMs(deadline), + }); + return /mCurrentOrientation=(\d)/.exec(result.stdout)?.[1]; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `orientation ${orientation} could not confirm the display rotation: ${error instanceof Error ? error.message : String(error)}`, + { hint: 'The device did not answer `dumpsys display` within the orientation budget.' }, + ); + } +} + +function remainingMs(deadline: number): number { + return Math.max(1, deadline - Date.now()); } export async function appSwitcherAndroid(device: DeviceInfo): Promise { diff --git a/test/integration/provider-scenarios/android-ime-lifecycle-world.ts b/test/integration/provider-scenarios/android-ime-lifecycle-world.ts index 98920c4890..be84c30752 100644 --- a/test/integration/provider-scenarios/android-ime-lifecycle-world.ts +++ b/test/integration/provider-scenarios/android-ime-lifecycle-world.ts @@ -8,6 +8,8 @@ export type AndroidProviderShellState = { searchText: string; clipboardText: string; secureSettings: Map; + /** The last `settings put system user_rotation`; the scripted display reports it back. */ + userRotation: string; }; const IME_INPUT_TEXT_ACTION = 'com.callstack.agentdevice.imehelper.ACTION_INPUT_TEXT_B64'; @@ -51,6 +53,7 @@ export function createAndroidProviderShellState(): AndroidProviderShellState { searchText: '', clipboardText: 'hello', secureSettings: new Map([['default_input_method', 'com.android.inputmethod.latin/.LatinIME']]), + userRotation: '0', }; } diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index 0501c4d73c..d8ebf72b1a 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -235,6 +235,7 @@ export function respondToAndroidSettingsAdbCommand( ): { stdout: string; stderr: string; exitCode: number; stdoutBuffer?: Buffer } { const key = args.join(' '); const result = + androidDisplayRotationAdbResult(key, options.ime) ?? androidDeviceAvailabilityAdbResult(key, args, options.pidof) ?? androidImeLifecycleAdbResult(key, args, options.ime) ?? androidClipboardAdbResult(key, clipboardText) ?? @@ -254,6 +255,10 @@ type AndroidAdbResult = { const ANDROID_CLIPBOARD_SET_TEXT_PREFIX = ['shell', 'cmd', 'clipboard', 'set', 'text']; function updateAndroidProviderShellState(args: string[], state: AndroidProviderShellState): void { + if (argsStartWith(args, ['shell', 'settings', 'put', 'system', 'user_rotation'])) { + state.userRotation = String(args[5] ?? '0'); + return; + } if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'text') { state.searchText = String(args[3] ?? '').replaceAll('%s', ' '); return; @@ -484,6 +489,19 @@ function androidPermissionMutationAdbResult(args: string[]): AndroidAdbResult | return undefined; } +/** The scripted display rotates the moment `user_rotation` lands, the way the settle expects. */ +function androidDisplayRotationAdbResult( + key: string, + state: AndroidProviderShellState | undefined, +): AndroidAdbResult | undefined { + if (key !== 'shell dumpsys display') return undefined; + return { + stdout: ` mCurrentOrientation=${state?.userRotation ?? '0'}\n`, + stderr: '', + exitCode: 0, + }; +} + function androidSettingsPutAdbResult(args: string[]): AndroidAdbResult | undefined { if ( args.length === 6 &&