Skip to content

Commit ffb7ed1

Browse files
committed
test(android): cover helper app admission and launch protocol
1 parent 01a9562 commit ffb7ed1

4 files changed

Lines changed: 87 additions & 3 deletions

File tree

‎packages/platform-android/src/app-lifecycle.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { classifyAndroidAppTarget } from './open-target.ts';
12
import { AppError } from '@agent-device/kernel/errors';
23
import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime';
34
import { Deadline, sleep } from '@agent-device/host-kit/retry';
@@ -252,7 +253,7 @@ async function tryAndroidHelperLaunch(
252253
options.activity !== undefined ||
253254
androidLaunchArgs(options).length > 0 ||
254255
options.terminateRunningApp ||
255-
!/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/.test(app)
256+
classifyAndroidAppTarget(app) !== 'package'
256257
)
257258
return undefined;
258259
const { runAndroidAutomationHelper } = await import('./automation-helper.ts');
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { afterEach, expect, test } from 'vitest';
2+
import { once } from 'node:events';
3+
import { openAndroidApp } from './app-lifecycle.ts';
4+
import { withAndroidAdbProvider } from './adb-executor.ts';
5+
import { readAndroidFillTargetBeforeMutation } from './fill-verification.ts';
6+
import { resetAndroidSnapshotHelperSessions } from './snapshot-helper-session-lifecycle.ts';
7+
import {
8+
createPersistentSnapshotHelperProvider,
9+
type FakeAndroidProcess,
10+
} from './__tests__/snapshot-helper-session.fixtures.ts';
11+
import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT as artifact } from './__tests__/test-utils/android-snapshot-helper.ts';
12+
import { ANDROID_EMULATOR as device } from './__tests__/test-utils/device-fixtures.ts';
13+
afterEach(resetAndroidSnapshotHelperSessions);
14+
15+
test.each(['advertised', 'unsupported', 'ended'] as const)(
16+
'app open through an %s session uses exactly one launch transport',
17+
async (mode) => {
18+
const calls: (readonly string[])[] = [],
19+
requests: string[] = [],
20+
processes: FakeAndroidProcess[] = [];
21+
const pkg = 'com.example.app';
22+
const provider = createPersistentSnapshotHelperProvider({
23+
calls,
24+
processes,
25+
spawnArgs: [],
26+
sessionRequests: requests,
27+
sessionCommands:
28+
mode === 'unsupported' ? ['snapshot-immediate'] : ['snapshot-immediate', 'app'],
29+
sessionCommand: (command) =>
30+
command.startsWith('app ')
31+
? {
32+
launchOutputBase64: Buffer.from(
33+
`Status: ok\nActivity: ${pkg}/.MainActivity\n`,
34+
).toString('base64'),
35+
}
36+
: undefined,
37+
});
38+
const exec = provider.exec;
39+
await withAndroidAdbProvider(
40+
{
41+
...provider,
42+
snapshotHelperArtifact: artifact,
43+
exec: async (args, options) => {
44+
if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') {
45+
calls.push(args);
46+
return {
47+
stdout: `Status: ok\nActivity: ${pkg}/.MainActivity\n`,
48+
stderr: '',
49+
exitCode: 0,
50+
};
51+
}
52+
return await exec(args, options);
53+
},
54+
},
55+
{ serial: device.id },
56+
async () => {
57+
await readAndroidFillTargetBeforeMutation(device, 10, 10, {
58+
helperSessionScope: 'daemon-session',
59+
});
60+
if (mode === 'ended') {
61+
const exited = once(processes[0]!, 'close');
62+
processes[0]!.kill();
63+
await exited;
64+
}
65+
await expect(openAndroidApp(device, pkg)).resolves.toBe(pkg);
66+
},
67+
);
68+
const appRequests = requests.filter((request) => request.startsWith('app '));
69+
expect(appRequests).toHaveLength(mode === 'advertised' ? 1 : 0);
70+
expect(
71+
calls.filter((args) => args[0] === 'shell' && args[1] === 'am' && args[2] === 'start'),
72+
).toHaveLength(mode === 'advertised' ? 0 : 1);
73+
if (mode === 'advertised')
74+
expect(JSON.parse(Buffer.from(appRequests[0]!.split(' ')[2]!, 'base64').toString())).toEqual({
75+
kind: 'open',
76+
packageName: pkg,
77+
});
78+
if (mode === 'ended')
79+
expect(calls.some((args) => args[0] === 'forward' && args[1] === '--remove')).toBe(true);
80+
},
81+
);

‎packages/platform-android/src/automation-helper-fallback.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ test('a validated no-injection refusal completes fill through adb without a help
7171
const f = fixture({ ...refused, injected: 'false' });
7272
await withAndroidAdbProvider(f.provider, { serial: device.id }, async () => {
7373
await fillAndroid(device, 10, 10, 'chips', 0, helper);
74-
expect((await readAndroidFillTargetBeforeMutation(device, 10, 10, helper))?.text).toBe('chips');
74+
expect(
75+
(await readAndroidFillTargetBeforeMutation(device, 10, 10, helper))?.targetInput?.text,
76+
).toBe('chips');
7577
});
7678
expect(f.calls.filter((args) => args[0] === 'shell' && args[2] === 'text')).toHaveLength(1);
7779
expect(f.requests.filter((request) => request.startsWith('input '))).toHaveLength(1);

‎src/commands/interaction/runtime/moving-target-gate.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import type { ResolveInteractionTargetParams } from './interaction-resolution-re
2828
* photograph of a surface that may still be rearranging itself, and the ADB-backed Android helper is
2929
* measured reporting frames in flight — so an interaction issued right after an action that moved
3030
* the UI can resolve a point the target has already left and report success on the vacancy. The exposure is the runtime path's own capture-to-dispatch
31-
* distance, so the gate is backend-neutral.
31+
* distance. Android requires a bounded quiet interval; other platforms retain the full observation window.
3232
*
3333
* Two judgements decide when it is real: {@link movingTargetGateDecision} asks whether this position
3434
* is still owed a second observation, and kernel's `areCapturedRectsAtSamePosition` asks whether the

0 commit comments

Comments
 (0)