mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
committed by
GitHub
parent
7bcbf1350b
commit
64b7cc45d4
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user