Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 107 additions & 8 deletions packages/platform-android/src/__tests__/input-actions.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test } from 'vitest';
import { test, vi } from 'vitest';
import assert from 'node:assert/strict';
import {
backAndroid,
Expand Down Expand Up @@ -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,
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
63 changes: 63 additions & 0 deletions packages/platform-android/src/input-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,6 +53,7 @@ export function createAndroidProviderShellState(): AndroidProviderShellState {
searchText: '',
clipboardText: 'hello',
secureSettings: new Map([['default_input_method', 'com.android.inputmethod.latin/.LatinIME']]),
userRotation: '0',
};
}

Expand Down
18 changes: 18 additions & 0 deletions test/integration/provider-scenarios/android-world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ??
Expand All @@ -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;
Expand Down Expand Up @@ -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 &&
Expand Down
Loading