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
2 changes: 1 addition & 1 deletion scripts/__tests__/test-file-size-ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const PINNED_TEST_FILE_LINES: Readonly<Record<string, number>> = Object.freeze({
'src/platforms/apple/core/__tests__/runner-session.test.ts': 2001,
'src/utils/__tests__/daemon-client.test.ts': 1910,
'src/utils/__tests__/output.test.ts': 1861,
'src/platforms/android/__tests__/snapshot.test.ts': 1658,
'src/platforms/android/__tests__/snapshot.test.ts': 1495,
'src/platforms/apple/core/__tests__/runner-client.test.ts': 1615,
'src/__tests__/client.test.ts': 1598,
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1559,
Expand Down
50 changes: 50 additions & 0 deletions src/core/interactors/android.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,31 @@ import {
import type { DeviceInfo } from '@agent-device/kernel/device';
import { createAndroidInteractor } from './android.ts';
import { snapshotAndroid } from '../../platforms/android/snapshot.ts';
import { scrollAndroid } from '../../platforms/android/input-actions.ts';
import { fillAndroid } from '../../platforms/android/text-input.ts';

vi.mock('../../platforms/android/snapshot.ts', () => ({
snapshotAndroid: vi.fn(),
}));
vi.mock('../../platforms/android/input-actions.ts', () => ({
scrollAndroid: vi.fn(),
appSwitcherAndroid: vi.fn(),
backAndroid: vi.fn(),
focusAndroid: vi.fn(),
homeAndroid: vi.fn(),
longPressAndroid: vi.fn(),
pressAndroid: vi.fn(),
pressAndroidTvRemote: vi.fn(),
setAndroidOrientation: vi.fn(),
}));
vi.mock('../../platforms/android/text-input.ts', () => ({
fillAndroid: vi.fn(),
typeAndroid: vi.fn(),
}));

const snapshotAndroidMock = vi.mocked(snapshotAndroid);
const fillAndroidMock = vi.mocked(fillAndroid);
const scrollAndroidMock = vi.mocked(scrollAndroid);
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
Expand Down Expand Up @@ -43,3 +62,34 @@ test('preserves Android clickability evidence through the interactor snapshot ad
expect(readSnapshotClickabilityEvidence(result)).toEqual(evidence);
expect(JSON.stringify(result)).not.toContain('clickable');
});

test('an app-backed session keeps the helper warm across fill and scroll', async () => {
const interactor = createAndroidInteractor(device, undefined, {
appBundleId: 'com.example.app',
});

await interactor.fill(10, 20, 'chips');
await interactor.scroll('down', { amount: 1 });

expect(fillAndroidMock).toHaveBeenCalledWith(device, 10, 20, 'chips', undefined, {
helperSessionScope: 'daemon-session',
});
expect(scrollAndroidMock).toHaveBeenCalledWith(device, 'down', {
amount: 1,
helperSessionScope: 'daemon-session',
});
});

test('a device-only session releases the helper after fill and scroll', async () => {
const interactor = createAndroidInteractor(device);

await interactor.fill(10, 20, 'chips');
await interactor.scroll('down');

expect(fillAndroidMock).toHaveBeenCalledWith(device, 10, 20, 'chips', undefined, {
helperSessionScope: 'command',
});
expect(scrollAndroidMock).toHaveBeenCalledWith(device, 'down', {
helperSessionScope: 'command',
});
});
37 changes: 25 additions & 12 deletions src/core/interactors/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@ import {
import {
appSwitcherAndroid,
backAndroid,
fillAndroid,
focusAndroid,
homeAndroid,
longPressAndroid,
pressAndroid,
pressAndroidTvRemote,
scrollAndroid,
setAndroidOrientation,
typeAndroid,
} from '../../platforms/android/input-actions.ts';
import { fillAndroid, typeAndroid } from '../../platforms/android/text-input.ts';
import {
executeAndroidTouchPlan,
readAndroidGestureViewport,
Expand All @@ -30,6 +29,7 @@ import {
} from '../../platforms/android/device-input-state.ts';
import { setAndroidSetting } from '../../platforms/android/settings.ts';
import { snapshotAndroid } from '../../platforms/android/snapshot.ts';
import type { AndroidHelperSessionScope } from '../../platforms/android/snapshot-helper-types.ts';
import { screenshotAndroid } from '../../platforms/android/screenshot.ts';
import { withDiagnosticTimer } from '../../utils/diagnostics.ts';
import { withMethodScope } from '../../utils/method-scope.ts';
Expand All @@ -40,11 +40,21 @@ import {
snapshotCaptureAnnotationsFrom,
} from '@agent-device/contracts/capture';

/**
* `appBundleId` is present exactly for app-backed daemon sessions, whose teardown releases the
* helper. Standalone device work has no such owner, so it releases the helper per command and
* leaves nothing squatting UiAutomation.
*/
function androidHelperSessionScope(appBundleId: string | undefined): AndroidHelperSessionScope {
return appBundleId ? 'daemon-session' : 'command';
}

export function createAndroidInteractor(
device: DeviceInfo,
provider?: AndroidAdbProvider,
runnerContext?: Pick<RunnerContext, 'signal'>,
runnerContext?: Pick<RunnerContext, 'signal' | 'appBundleId'>,
): Interactor {
const helperSessionScope = androidHelperSessionScope(runnerContext?.appBundleId);
const interactor: Interactor = {
open: (app, options) =>
openAndroidApp(device, app, {
Expand All @@ -63,15 +73,20 @@ export function createAndroidInteractor(
longPress: (x, y, durationMs) => longPressAndroid(device, x, y, durationMs),
focus: (x, y) => focusAndroid(device, x, y),
type: (text, delayMs) => typeAndroid(device, text, delayMs),
fill: (x, y, text, delayMs) => fillAndroid(device, x, y, text, delayMs),
scroll: (direction, options) => scrollAndroid(device, direction, options),
fill: (x, y, text, delayMs) => fillAndroid(device, x, y, text, delayMs, { helperSessionScope }),
scroll: (direction, options) =>
scrollAndroid(device, direction, { ...options, helperSessionScope }),
performGesture: (plan) => executeAndroidTouchPlan(device, plan),
gestureViewport: () => readAndroidGestureViewport(device),
gestureViewport: () => readAndroidGestureViewport(device, { helperSessionScope }),
screenshot: (outPath, options) => screenshotAndroid(device, outPath, options),
// uiautomator reads the node covering a point; `undefined` means nothing covers it.
readTextAtPoint: async (point) => {
const { readAndroidTextAtPoint } = await import('../../platforms/android/input-actions.ts');
return (await readAndroidTextAtPoint(device, point.x, point.y)) ?? undefined;
readTextAtPoint: async (point, options) => {
const { readAndroidTextAtPoint } =
await import('../../platforms/android/fill-verification.ts');
const read = await readAndroidTextAtPoint(device, point.x, point.y, {
helperSessionScope: androidHelperSessionScope(options?.appBundleId),
});
return read ?? undefined;
},
snapshot: async (options) => {
const snapshotOptions = options ?? {};
Expand All @@ -86,9 +101,7 @@ export function createAndroidInteractor(
scope: snapshotOptions.scope,
raw: snapshotOptions.raw,
includeHiddenContentHints: snapshotOptions.includeHiddenContentHints,
// appBundleId is present for app-backed daemon sessions; keep the helper warm there,
// but release it after standalone device snapshots so UiAutomation is not squatted.
helperSessionScope: snapshotOptions.appBundleId ? 'daemon-session' : 'command',
helperSessionScope: androidHelperSessionScope(snapshotOptions.appBundleId),
}),
{ backend: 'android' },
);
Expand Down
101 changes: 101 additions & 0 deletions src/platforms/android/__tests__/adb-shell-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import { beforeEach, test } from 'vitest';
import {
androidAdbForwardsDeviceExitStatus,
resetAndroidAdbShellProtocolProbes,
} from '../adb-shell-protocol.ts';
import type { AndroidAdbExecutor } from '../adb-executor.ts';

beforeEach(() => {
resetAndroidAdbShellProtocolProbes();
});

test('reads the negotiated feature set as proof that adb forwards device exit status', async () => {
const calls: string[][] = [];
const adb = featuresAdb(calls, {
exitCode: 0,
stdout: 'sendrecv_v2\nstat_v2\nshell_v2\ncmd\n',
stderr: '',
});

assert.equal(
await androidAdbForwardsDeviceExitStatus({ adb, deviceKey: 'android:emulator-5554' }),
true,
);
assert.deepEqual(calls, [['features']]);
});

test('treats a transport without shell protocol v2 as unable to prove a device exit', async () => {
const adb = featuresAdb([], { exitCode: 0, stdout: 'stat_v2\ncmd\n', stderr: '' });

assert.equal(
await androidAdbForwardsDeviceExitStatus({ adb, deviceKey: 'android:emulator-5554' }),
false,
);
});

test('treats an unanswered probe as unknown rather than caching it as unsupported', async () => {
const calls: string[][] = [];
let answered = false;
const adb: AndroidAdbExecutor = async (args) => {
calls.push(args);
if (!answered) {
answered = true;
return { exitCode: 1, stdout: '', stderr: 'adb: unknown command features' };
}
return { exitCode: 0, stdout: 'shell_v2\n', stderr: '' };
};

assert.equal(
await androidAdbForwardsDeviceExitStatus({ adb, deviceKey: 'android:emulator-5554' }),
false,
);
// The failed probe proved nothing, so it is not remembered as an answer.
assert.equal(
await androidAdbForwardsDeviceExitStatus({ adb, deviceKey: 'android:emulator-5554' }),
true,
);
assert.equal(calls.length, 2);
});

test('reports no proof when the probe itself throws', async () => {
const adb: AndroidAdbExecutor = async () => {
throw new Error('device offline');
};

assert.equal(
await androidAdbForwardsDeviceExitStatus({ adb, deviceKey: 'android:emulator-5554' }),
false,
);
});

test('answers each device from its own negotiated feature set', async () => {
const adb: AndroidAdbExecutor = async () => ({
exitCode: 0,
stdout: 'shell_v2\n',
stderr: '',
});
const legacyAdb: AndroidAdbExecutor = async () => ({ exitCode: 0, stdout: 'cmd\n', stderr: '' });

assert.equal(
await androidAdbForwardsDeviceExitStatus({ adb, deviceKey: 'android:emulator-5554' }),
true,
);
assert.equal(
await androidAdbForwardsDeviceExitStatus({
adb: legacyAdb,
deviceKey: 'android:legacy-device',
}),
false,
);
});

function featuresAdb(
calls: string[][],
result: { exitCode: number; stdout: string; stderr: string },
): AndroidAdbExecutor {
return async (args) => {
calls.push(args);
return result;
};
}
Loading
Loading