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 packages/contracts/src/android-observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type AndroidObservationHost = Readonly<{
runAdb(
device: DeviceInfo,
args: readonly string[],
options?: Readonly<{ allowFailure?: boolean }>,
options?: Readonly<{ allowFailure?: boolean; timeoutMs?: number }>,
): Promise<AndroidObservationCommandResult>;
readSnapshotNodes(device: DeviceInfo): Promise<SnapshotNode[]>;
openApp(device: DeviceInfo, appBundleId: string): Promise<void>;
Expand Down
190 changes: 190 additions & 0 deletions packages/platform-android/src/observation.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { expect, test, vi } from 'vitest';
import type { AndroidObservationHost } from '@agent-device/contracts/android-observation';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import { runAndroidAutomationHelper } from './automation-helper.ts';
import { createAndroidObservationAdapter } from './observation.ts';

vi.mock('./automation-helper.ts', async (importOriginal) => ({
...(await importOriginal<typeof import('./automation-helper.ts')>()),
runAndroidAutomationHelper: vi.fn(async () => undefined),
}));

const device = {
platform: 'android',
target: 'mobile',
Expand Down Expand Up @@ -98,3 +105,186 @@ test('a transient empty dump never demotes a variant that previously answered',

expect(calls[thirdReadStart]).toBe('shell dumpsys window windows');
});

function timedOutAdb(args: readonly string[], timeoutMs: number): AppError {
return new AppError('COMMAND_FAILED', 'adb timed out', { cmd: 'adb', args, timeoutMs });
}

type Observer = ReturnType<typeof createAndroidObservationAdapter>;

const QUESTIONS = [
{
question: 'foreground',
ask: (observer: Observer, target: DeviceInfo) => observer.readAppState(target),
unanswered: {},
},
{
question: 'blocking dialog',
ask: (observer: Observer, target: DeviceInfo) => observer.readBlockingDialog(target),
unanswered: { status: 'unknown' },
},
] as const;

/**
* A host whose every dump answers without a focus line after `spendMs(timeoutMs)` of a virtual
* clock, optionally timing out instead, so an observation walks every variant it is allowed to.
*/
function slowHost(params: { spendMs: (timeoutMs: number) => number; timeOut?: boolean }): {
host: AndroidObservationHost;
timeouts: number[];
advance: (ms: number) => void;
restore: () => void;
} {
const originMs = Date.now();
let elapsedMs = 0;
const now = vi.spyOn(Date, 'now').mockImplementation(() => originMs + elapsedMs);
const timeouts: number[] = [];
const host: AndroidObservationHost = {
...hostFor(new Map()),
runAdb: vi.fn(async (_device, args, options) => {
const timeoutMs = options?.timeoutMs ?? Number.POSITIVE_INFINITY;
timeouts.push(timeoutMs);
elapsedMs += params.spendMs(timeoutMs);
if (params.timeOut) throw timedOutAdb(args, timeoutMs);
return { exitCode: 0, stdout: '', stderr: '' };
}),
};
return {
host,
timeouts,
advance: (ms) => {
elapsedMs += ms;
},
restore: () => now.mockRestore(),
};
}

test('bounds every dump read the foreground question makes', async () => {
const { host, timeouts, restore } = slowHost({ spendMs: () => 0 });
try {
await createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-bounded' });
} finally {
restore();
}

expect(vi.mocked(host.runAdb).mock.calls.map(([, args]) => args.join(' '))).toEqual([
'shell dumpsys window windows',
'shell dumpsys window',
'shell dumpsys activity activities',
'shell dumpsys activity',
]);
for (const timeoutMs of timeouts) expect(Number.isFinite(timeoutMs) && timeoutMs > 0).toBe(true);
});

test('later variants receive only what the earlier reads left of one observation budget', async () => {
const { host, timeouts, restore } = slowHost({
spendMs: (timeoutMs) => Math.floor(timeoutMs / 2),
});
try {
await createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-slow' });
} finally {
restore();
}

const budgetMs = timeouts[0]!;
expect(timeouts.length).toBeGreaterThan(1);
for (let index = 1; index < timeouts.length; index += 1) {
expect(timeouts[index]).toBe(timeouts[index - 1]! - Math.floor(timeouts[index - 1]! / 2));
}
const spentMs = timeouts.reduce((sum, timeoutMs) => sum + Math.floor(timeoutMs / 2), 0);
expect(spentMs).toBeLessThanOrEqual(budgetMs);
});

test.each(
QUESTIONS.flatMap((entry) => [
{ ...entry, ending: 'times out', timeOut: true },
{ ...entry, ending: 'answers at its bound', timeOut: false },
]),
)(
'a $question read that $ending spends the observation budget and leaves it unanswered',
async ({ question, ask, unanswered, timeOut }) => {
const { host, restore } = slowHost({ spendMs: (timeoutMs) => timeoutMs, timeOut });
try {
await expect(
ask(createAndroidObservationAdapter(host), { ...device, id: `emulator-${question}` }),
).resolves.toEqual(unanswered);
} finally {
restore();
}
expect(host.runAdb).toHaveBeenCalledTimes(1);
},
);

test('a dump that fails without timing out still fails the observation', async () => {
const failure = new AppError('COMMAND_FAILED', 'adb: device offline', { cmd: 'adb' });
const host: AndroidObservationHost = {
...hostFor(new Map()),
runAdb: vi.fn(async () => {
throw failure;
}),
};

await expect(
createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-offline' }),
).rejects.toBe(failure);
});

test('the helper focus call before the first dump does not spend the dump budget', async () => {
const unhurried = slowHost({ spendMs: () => 0 });
try {
await createAndroidObservationAdapter(unhurried.host).readBlockingDialog({
...device,
id: 'emulator-focus-fast',
});
} finally {
unhurried.restore();
}

const slowFocus = slowHost({ spendMs: () => 0 });
const focusCostMs = Math.floor(unhurried.timeouts[0]! / 2);
vi.mocked(runAndroidAutomationHelper).mockImplementationOnce(async () => {
slowFocus.advance(focusCostMs);
return undefined;
});
try {
await createAndroidObservationAdapter(slowFocus.host).readBlockingDialog({
...device,
id: 'emulator-focus-slow',
});
} finally {
slowFocus.restore();
}

expect(slowFocus.timeouts[0]).toBe(unhurried.timeouts[0]);
});

test('a read that starts with the budget nearly spent never passes a non-positive timeout', async () => {
// Each clock read advances 1 ms, so two reads of one deadline disagree by one millisecond.
const originMs = Date.now();
let elapsedMs = 0;
const now = vi.spyOn(Date, 'now').mockImplementation(() => {
elapsedMs += 1;
return originMs + elapsedMs;
});
const timeouts: number[] = [];
const host: AndroidObservationHost = {
...hostFor(new Map()),
runAdb: vi.fn(async (_device, _args, options) => {
const timeoutMs = options?.timeoutMs ?? Number.POSITIVE_INFINITY;
timeouts.push(timeoutMs);
elapsedMs += timeoutMs - 2;
return { exitCode: 0, stdout: '', stderr: '' };
}),
};
try {
await createAndroidObservationAdapter(host).readAppState({
...device,
id: 'emulator-rollover',
});
} finally {
now.mockRestore();
}

expect(timeouts.length).toBeGreaterThan(1);
for (const timeoutMs of timeouts) expect(timeoutMs).toBeGreaterThan(0);
});
35 changes: 29 additions & 6 deletions packages/platform-android/src/observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {
} from '@agent-device/contracts/android-observation';
import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { isCommandTimeoutError } from '@agent-device/host-kit/command';
import { Deadline } from '@agent-device/host-kit/retry';
import { deviceShellArgv } from '@agent-device/kernel/device-shell';
import { AppError } from '@agent-device/kernel/errors';
import {
Expand Down Expand Up @@ -32,6 +34,8 @@ const PERMISSION_PACKAGES = new Set([
'com.google.android.packageinstaller',
]);

const DUMP_OBSERVATION_BUDGET_MS = 10_000;

type Question = 'foreground' | 'blockingDialog';
type DumpReader = (words: readonly string[]) => Promise<string>;
const everAnswered = new Map<string, Map<string, boolean>>();
Expand Down Expand Up @@ -79,18 +83,37 @@ export function createAndroidObservationAdapter(
});
}

/**
* Every variant one observation reads shares a single {@link DUMP_OBSERVATION_BUDGET_MS} budget,
* counted from its first dump read, so an observation costs at most one bound however many
* variants it walks. A read that times out or finds the budget spent is an unanswered read, like
* one that printed nothing: the observation reports `unknown` and never refuses the command.
*/
function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): DumpReader {
const dumps = new Map<string, Promise<string>>();
let deadline: Deadline | undefined;
return (words) => {
const key = words.join(' ');
const pending =
dumps.get(key) ??
host
.runAdb(device, deviceShellArgv('adb', 'shell', words), { allowFailure: true })
.then((result) => {
const cached = dumps.get(key);
if (cached) return cached;
deadline ??= Deadline.fromTimeoutMs(DUMP_OBSERVATION_BUDGET_MS);
const remainingMs = deadline.remainingMs();
if (remainingMs <= 0) return Promise.resolve('');
const pending = host
.runAdb(device, deviceShellArgv('adb', 'shell', words), {
allowFailure: true,
timeoutMs: remainingMs,
})
.then(
(result) => {
recordSections(device, key, result.stdout);
return result.stdout;
});
},
(error: unknown) => {
if (!isCommandTimeoutError(error)) throw error;
return '';
},
);
dumps.set(key, pending);
return pending;
};
Expand Down
Loading