fix(android): return from orientation once the display reports the rotation (#2356)

`orientation` wrote accelerometer_rotation and user_rotation and returned
at once, while the display rotated some time later. On the loaded CI
emulator that takes seconds, and accessibility reads hang meanwhile: the
Android smoke's `wait text landscape` right after `orientation
landscape-left` got a helper request timeout and then no readable
capture for its whole 10s budget, with the failed-step snapshot taken
afterwards already in landscape (PR #2344, run 34025424834).

The command now polls `dumpsys display` for mCurrentOrientation to
match the requested rotation before returning, each probe bounded by
what is left of the 15s settle budget so a stuck probe ends the settle
as a failure. A display that never gets there fails the command with the
observed rotation instead of reporting success; a display that reports
no rotation at all is left to the setting as before. The provider
scenario scripts the display read against the last user_rotation write.
This commit is contained in:
Michał Pierzchała
2026-09-07 10:14:09 +02:00
committed by GitHub
parent 7bcbf1350b
commit 64b7cc45d4
6 changed files with 203 additions and 10 deletions
+7
View File
@@ -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
@@ -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<string, unknown> }).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,
]);
});
});
@@ -10,7 +10,10 @@ import { ANDROID_EMULATOR } from './device-fixtures.ts';
import { bindAndroidAdbTestHost } from './android-host-test-setup.ts';
export type FakeAdbResponse = string | Partial<AndroidAdbExecutorResult> | 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<T>(
execOptions?: AndroidAdbExecutorOptions,
): Promise<AndroidAdbExecutorResult> => {
calls.push([...args]);
const response = script(args);
const response = script(args, execOptions);
if (response instanceof Error) throw response;
const result: AndroidAdbExecutorResult =
typeof response === 'string'
@@ -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<void> {
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<string | undefined> {
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<void> {
@@ -8,6 +8,8 @@ export type AndroidProviderShellState = {
searchText: string;
clipboardText: string;
secureSettings: Map<string, string>;
/** 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',
};
}
@@ -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 &&