agent-device 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -9
- package/dist/src/797.js +1 -1
- package/dist/src/bin.js +5 -5
- package/dist/src/daemon.js +16 -20
- package/package.json +7 -9
- package/skills/agent-device/SKILL.md +3 -6
- package/skills/agent-device/references/permissions.md +3 -15
- package/skills/agent-device/references/snapshot-refs.md +1 -4
- package/dist/bin/axsnapshot +0 -0
- package/ios-runner/AXSnapshot/Package.swift +0 -18
- package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
- package/src/__tests__/cli-close.test.ts +0 -155
- package/src/__tests__/cli-help.test.ts +0 -102
- package/src/bin.ts +0 -3
- package/src/cli.ts +0 -305
- package/src/core/__tests__/capabilities.test.ts +0 -75
- package/src/core/__tests__/dispatch-open.test.ts +0 -25
- package/src/core/__tests__/open-target.test.ts +0 -55
- package/src/core/capabilities.ts +0 -57
- package/src/core/dispatch.ts +0 -382
- package/src/core/open-target.ts +0 -27
- package/src/daemon/__tests__/device-ready.test.ts +0 -52
- package/src/daemon/__tests__/is-predicates.test.ts +0 -68
- package/src/daemon/__tests__/selectors.test.ts +0 -261
- package/src/daemon/__tests__/session-routing.test.ts +0 -108
- package/src/daemon/__tests__/session-selector.test.ts +0 -64
- package/src/daemon/__tests__/session-store.test.ts +0 -142
- package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
- package/src/daemon/action-utils.ts +0 -29
- package/src/daemon/context.ts +0 -48
- package/src/daemon/device-ready.ts +0 -155
- package/src/daemon/handlers/__tests__/find.test.ts +0 -99
- package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
- package/src/daemon/handlers/__tests__/session.test.ts +0 -820
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
- package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
- package/src/daemon/handlers/find.ts +0 -324
- package/src/daemon/handlers/interaction.ts +0 -550
- package/src/daemon/handlers/parse-utils.ts +0 -8
- package/src/daemon/handlers/record-trace.ts +0 -154
- package/src/daemon/handlers/session.ts +0 -1137
- package/src/daemon/handlers/snapshot.ts +0 -439
- package/src/daemon/is-predicates.ts +0 -46
- package/src/daemon/selectors.ts +0 -540
- package/src/daemon/session-routing.ts +0 -22
- package/src/daemon/session-selector.ts +0 -39
- package/src/daemon/session-store.ts +0 -296
- package/src/daemon/snapshot-processing.ts +0 -131
- package/src/daemon/types.ts +0 -56
- package/src/daemon-client.ts +0 -272
- package/src/daemon.ts +0 -295
- package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
- package/src/platforms/android/__tests__/index.test.ts +0 -274
- package/src/platforms/android/devices.ts +0 -196
- package/src/platforms/android/index.ts +0 -784
- package/src/platforms/android/ui-hierarchy.ts +0 -312
- package/src/platforms/boot-diagnostics.ts +0 -128
- package/src/platforms/ios/__tests__/index.test.ts +0 -312
- package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
- package/src/platforms/ios/apps.ts +0 -358
- package/src/platforms/ios/ax-snapshot.ts +0 -207
- package/src/platforms/ios/config.ts +0 -28
- package/src/platforms/ios/devicectl.ts +0 -134
- package/src/platforms/ios/devices.ts +0 -100
- package/src/platforms/ios/index.ts +0 -20
- package/src/platforms/ios/runner-client.ts +0 -994
- package/src/platforms/ios/simulator.ts +0 -164
- package/src/utils/__tests__/args.test.ts +0 -239
- package/src/utils/__tests__/daemon-client.test.ts +0 -95
- package/src/utils/__tests__/exec.test.ts +0 -16
- package/src/utils/__tests__/finders.test.ts +0 -34
- package/src/utils/__tests__/keyed-lock.test.ts +0 -55
- package/src/utils/__tests__/process-identity.test.ts +0 -33
- package/src/utils/__tests__/retry.test.ts +0 -44
- package/src/utils/args.ts +0 -239
- package/src/utils/command-schema.ts +0 -622
- package/src/utils/device.ts +0 -84
- package/src/utils/errors.ts +0 -35
- package/src/utils/exec.ts +0 -339
- package/src/utils/finders.ts +0 -101
- package/src/utils/interactive.ts +0 -4
- package/src/utils/interactors.ts +0 -173
- package/src/utils/keyed-lock.ts +0 -14
- package/src/utils/output.ts +0 -204
- package/src/utils/process-identity.ts +0 -100
- package/src/utils/retry.ts +0 -180
- package/src/utils/snapshot.ts +0 -64
- package/src/utils/timeouts.ts +0 -9
- package/src/utils/version.ts +0 -26
|
@@ -1,784 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
2
|
-
import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
3
|
-
import { withRetry } from '../../utils/retry.ts';
|
|
4
|
-
import { AppError } from '../../utils/errors.ts';
|
|
5
|
-
import type { DeviceInfo } from '../../utils/device.ts';
|
|
6
|
-
import type { RawSnapshotNode, SnapshotOptions } from '../../utils/snapshot.ts';
|
|
7
|
-
import { isDeepLinkTarget } from '../../core/open-target.ts';
|
|
8
|
-
import { waitForAndroidBoot } from './devices.ts';
|
|
9
|
-
import { findBounds, parseBounds, parseUiHierarchy, readNodeAttributes } from './ui-hierarchy.ts';
|
|
10
|
-
|
|
11
|
-
const ALIASES: Record<string, { type: 'intent' | 'package'; value: string }> = {
|
|
12
|
-
settings: { type: 'intent', value: 'android.settings.SETTINGS' },
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function adbArgs(device: DeviceInfo, args: string[]): string[] {
|
|
16
|
-
return ['-s', device.id, ...args];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function resolveAndroidApp(
|
|
20
|
-
device: DeviceInfo,
|
|
21
|
-
app: string,
|
|
22
|
-
): Promise<{ type: 'intent' | 'package'; value: string }> {
|
|
23
|
-
const trimmed = app.trim();
|
|
24
|
-
if (trimmed.includes('.')) return { type: 'package', value: trimmed };
|
|
25
|
-
|
|
26
|
-
const alias = ALIASES[trimmed.toLowerCase()];
|
|
27
|
-
if (alias) return alias;
|
|
28
|
-
|
|
29
|
-
const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages']));
|
|
30
|
-
const packages = result.stdout
|
|
31
|
-
.split('\n')
|
|
32
|
-
.map((line: string) => line.replace('package:', '').trim())
|
|
33
|
-
.filter(Boolean);
|
|
34
|
-
|
|
35
|
-
const matches = packages.filter((pkg: string) =>
|
|
36
|
-
pkg.toLowerCase().includes(trimmed.toLowerCase()),
|
|
37
|
-
);
|
|
38
|
-
if (matches.length === 1) {
|
|
39
|
-
return { type: 'package', value: matches[0] };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (matches.length > 1) {
|
|
43
|
-
throw new AppError('INVALID_ARGS', `Multiple packages matched "${app}"`, { matches });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
throw new AppError('APP_NOT_INSTALLED', `No package found matching "${app}"`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function listAndroidApps(
|
|
50
|
-
device: DeviceInfo,
|
|
51
|
-
filter: 'user-installed' | 'all' = 'all',
|
|
52
|
-
): Promise<Array<{ package: string; name: string }>> {
|
|
53
|
-
const launchable = await listAndroidLaunchablePackages(device);
|
|
54
|
-
const packageIds =
|
|
55
|
-
filter === 'user-installed'
|
|
56
|
-
? (await listAndroidUserInstalledPackages(device)).filter((pkg) => launchable.has(pkg))
|
|
57
|
-
: Array.from(launchable);
|
|
58
|
-
return packageIds
|
|
59
|
-
.sort((a, b) => a.localeCompare(b))
|
|
60
|
-
.map((pkg) => ({ package: pkg, name: inferAndroidAppName(pkg) }));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function listAndroidLaunchablePackages(device: DeviceInfo): Promise<Set<string>> {
|
|
64
|
-
const result = await runCmd(
|
|
65
|
-
'adb',
|
|
66
|
-
adbArgs(device, [
|
|
67
|
-
'shell',
|
|
68
|
-
'cmd',
|
|
69
|
-
'package',
|
|
70
|
-
'query-activities',
|
|
71
|
-
'--brief',
|
|
72
|
-
'-a',
|
|
73
|
-
'android.intent.action.MAIN',
|
|
74
|
-
'-c',
|
|
75
|
-
'android.intent.category.LAUNCHER',
|
|
76
|
-
]),
|
|
77
|
-
{ allowFailure: true },
|
|
78
|
-
);
|
|
79
|
-
if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
|
|
80
|
-
return new Set<string>();
|
|
81
|
-
}
|
|
82
|
-
const packages = new Set<string>();
|
|
83
|
-
for (const line of result.stdout.split('\n')) {
|
|
84
|
-
const trimmed = line.trim();
|
|
85
|
-
if (!trimmed) continue;
|
|
86
|
-
const firstToken = trimmed.split(/\s+/)[0];
|
|
87
|
-
const pkg = firstToken.includes('/') ? firstToken.split('/')[0] : firstToken;
|
|
88
|
-
if (pkg) packages.add(pkg);
|
|
89
|
-
}
|
|
90
|
-
return packages;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function listAndroidUserInstalledPackages(device: DeviceInfo): Promise<string[]> {
|
|
94
|
-
const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages', '-3']));
|
|
95
|
-
return result.stdout
|
|
96
|
-
.split('\n')
|
|
97
|
-
.map((line: string) => line.replace('package:', '').trim())
|
|
98
|
-
.filter(Boolean);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function inferAndroidAppName(packageName: string): string {
|
|
102
|
-
const ignoredTokens = new Set([
|
|
103
|
-
'com',
|
|
104
|
-
'android',
|
|
105
|
-
'google',
|
|
106
|
-
'app',
|
|
107
|
-
'apps',
|
|
108
|
-
'service',
|
|
109
|
-
'services',
|
|
110
|
-
'mobile',
|
|
111
|
-
'client',
|
|
112
|
-
]);
|
|
113
|
-
const tokens = packageName
|
|
114
|
-
.split('.')
|
|
115
|
-
.flatMap((segment) => segment.split(/[_-]+/))
|
|
116
|
-
.map((token) => token.trim().toLowerCase())
|
|
117
|
-
.filter((token) => token.length > 0);
|
|
118
|
-
// Fallback to last token if every token is ignored (e.g. "com.android.app.services" → "Services").
|
|
119
|
-
let chosen = tokens[tokens.length - 1] ?? packageName;
|
|
120
|
-
for (let index = tokens.length - 1; index >= 0; index -= 1) {
|
|
121
|
-
const token = tokens[index];
|
|
122
|
-
if (!ignoredTokens.has(token)) {
|
|
123
|
-
chosen = token;
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return chosen
|
|
128
|
-
.split(/[^a-z0-9]+/i)
|
|
129
|
-
.filter(Boolean)
|
|
130
|
-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
131
|
-
.join(' ');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export async function getAndroidAppState(
|
|
135
|
-
device: DeviceInfo,
|
|
136
|
-
): Promise<{ package?: string; activity?: string }> {
|
|
137
|
-
const windowFocus = await readAndroidFocus(device, [
|
|
138
|
-
['shell', 'dumpsys', 'window', 'windows'],
|
|
139
|
-
['shell', 'dumpsys', 'window'],
|
|
140
|
-
]);
|
|
141
|
-
if (windowFocus) return windowFocus;
|
|
142
|
-
|
|
143
|
-
const activityFocus = await readAndroidFocus(device, [
|
|
144
|
-
['shell', 'dumpsys', 'activity', 'activities'],
|
|
145
|
-
['shell', 'dumpsys', 'activity'],
|
|
146
|
-
]);
|
|
147
|
-
if (activityFocus) return activityFocus;
|
|
148
|
-
return {};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async function readAndroidFocus(
|
|
152
|
-
device: DeviceInfo,
|
|
153
|
-
commands: string[][],
|
|
154
|
-
): Promise<{ package?: string; activity?: string } | null> {
|
|
155
|
-
for (const args of commands) {
|
|
156
|
-
const result = await runCmd('adb', adbArgs(device, args), { allowFailure: true });
|
|
157
|
-
const text = result.stdout ?? '';
|
|
158
|
-
const parsed = parseAndroidFocus(text);
|
|
159
|
-
if (parsed) return parsed;
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function parseAndroidFocus(text: string): { package?: string; activity?: string } | null {
|
|
165
|
-
const patterns = [
|
|
166
|
-
/mCurrentFocus=Window\{[^}]*\s([\w.]+)\/([\w.$]+)/,
|
|
167
|
-
/mFocusedApp=AppWindowToken\{[^}]*\s([\w.]+)\/([\w.$]+)/,
|
|
168
|
-
/mResumedActivity:.*?\s([\w.]+)\/([\w.$]+)/,
|
|
169
|
-
/ResumedActivity:.*?\s([\w.]+)\/([\w.$]+)/,
|
|
170
|
-
];
|
|
171
|
-
for (const pattern of patterns) {
|
|
172
|
-
const match = pattern.exec(text);
|
|
173
|
-
if (match) {
|
|
174
|
-
return { package: match[1], activity: match[2] };
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function openAndroidApp(
|
|
181
|
-
device: DeviceInfo,
|
|
182
|
-
app: string,
|
|
183
|
-
activity?: string,
|
|
184
|
-
): Promise<void> {
|
|
185
|
-
if (!device.booted) {
|
|
186
|
-
await waitForAndroidBoot(device.id);
|
|
187
|
-
}
|
|
188
|
-
const deepLinkTarget = app.trim();
|
|
189
|
-
if (isDeepLinkTarget(deepLinkTarget)) {
|
|
190
|
-
if (activity) {
|
|
191
|
-
throw new AppError('INVALID_ARGS', 'Activity override is not supported when opening a deep link URL');
|
|
192
|
-
}
|
|
193
|
-
await runCmd('adb', adbArgs(device, [
|
|
194
|
-
'shell',
|
|
195
|
-
'am',
|
|
196
|
-
'start',
|
|
197
|
-
'-W',
|
|
198
|
-
'-a',
|
|
199
|
-
'android.intent.action.VIEW',
|
|
200
|
-
'-d',
|
|
201
|
-
deepLinkTarget,
|
|
202
|
-
]));
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const resolved = await resolveAndroidApp(device, app);
|
|
206
|
-
if (resolved.type === 'intent') {
|
|
207
|
-
if (activity) {
|
|
208
|
-
throw new AppError('INVALID_ARGS', 'Activity override requires a package name, not an intent');
|
|
209
|
-
}
|
|
210
|
-
await runCmd('adb', adbArgs(device, ['shell', 'am', 'start', '-W', '-a', resolved.value]));
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
if (activity) {
|
|
214
|
-
const component = activity.includes('/')
|
|
215
|
-
? activity
|
|
216
|
-
: `${resolved.value}/${activity.startsWith('.') ? activity : `.${activity}`}`;
|
|
217
|
-
await runCmd(
|
|
218
|
-
'adb',
|
|
219
|
-
adbArgs(device, [
|
|
220
|
-
'shell',
|
|
221
|
-
'am',
|
|
222
|
-
'start',
|
|
223
|
-
'-W',
|
|
224
|
-
'-a',
|
|
225
|
-
'android.intent.action.MAIN',
|
|
226
|
-
'-c',
|
|
227
|
-
'android.intent.category.DEFAULT',
|
|
228
|
-
'-c',
|
|
229
|
-
'android.intent.category.LAUNCHER',
|
|
230
|
-
'-n',
|
|
231
|
-
component,
|
|
232
|
-
]),
|
|
233
|
-
);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
try {
|
|
237
|
-
await runCmd(
|
|
238
|
-
'adb',
|
|
239
|
-
adbArgs(device, [
|
|
240
|
-
'shell',
|
|
241
|
-
'am',
|
|
242
|
-
'start',
|
|
243
|
-
'-W',
|
|
244
|
-
'-a',
|
|
245
|
-
'android.intent.action.MAIN',
|
|
246
|
-
'-c',
|
|
247
|
-
'android.intent.category.DEFAULT',
|
|
248
|
-
'-c',
|
|
249
|
-
'android.intent.category.LAUNCHER',
|
|
250
|
-
'-p',
|
|
251
|
-
resolved.value,
|
|
252
|
-
]),
|
|
253
|
-
);
|
|
254
|
-
return;
|
|
255
|
-
} catch (initialError) {
|
|
256
|
-
const component = await resolveAndroidLaunchComponent(device, resolved.value);
|
|
257
|
-
if (!component) throw initialError;
|
|
258
|
-
await runCmd(
|
|
259
|
-
'adb',
|
|
260
|
-
adbArgs(device, [
|
|
261
|
-
'shell',
|
|
262
|
-
'am',
|
|
263
|
-
'start',
|
|
264
|
-
'-W',
|
|
265
|
-
'-a',
|
|
266
|
-
'android.intent.action.MAIN',
|
|
267
|
-
'-c',
|
|
268
|
-
'android.intent.category.DEFAULT',
|
|
269
|
-
'-c',
|
|
270
|
-
'android.intent.category.LAUNCHER',
|
|
271
|
-
'-n',
|
|
272
|
-
component,
|
|
273
|
-
]),
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
async function resolveAndroidLaunchComponent(
|
|
279
|
-
device: DeviceInfo,
|
|
280
|
-
packageName: string,
|
|
281
|
-
): Promise<string | null> {
|
|
282
|
-
const result = await runCmd(
|
|
283
|
-
'adb',
|
|
284
|
-
adbArgs(device, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', packageName]),
|
|
285
|
-
{ allowFailure: true },
|
|
286
|
-
);
|
|
287
|
-
if (result.exitCode !== 0) return null;
|
|
288
|
-
return parseAndroidLaunchComponent(result.stdout);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export function parseAndroidLaunchComponent(stdout: string): string | null {
|
|
292
|
-
const lines = stdout
|
|
293
|
-
.split('\n')
|
|
294
|
-
.map((line: string) => line.trim())
|
|
295
|
-
.filter(Boolean);
|
|
296
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
297
|
-
const line = lines[index];
|
|
298
|
-
if (!line.includes('/')) continue;
|
|
299
|
-
return line.split(/\s+/)[0];
|
|
300
|
-
}
|
|
301
|
-
return null;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export async function openAndroidDevice(device: DeviceInfo): Promise<void> {
|
|
305
|
-
if (!device.booted) {
|
|
306
|
-
await waitForAndroidBoot(device.id);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<void> {
|
|
311
|
-
const trimmed = app.trim();
|
|
312
|
-
if (trimmed.toLowerCase() === 'settings') {
|
|
313
|
-
await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', 'com.android.settings']));
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
const resolved = await resolveAndroidApp(device, app);
|
|
317
|
-
if (resolved.type === 'intent') {
|
|
318
|
-
throw new AppError('INVALID_ARGS', 'Close requires a package name, not an intent');
|
|
319
|
-
}
|
|
320
|
-
await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', resolved.value]));
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
export async function uninstallAndroidApp(
|
|
324
|
-
device: DeviceInfo,
|
|
325
|
-
app: string,
|
|
326
|
-
): Promise<{ package: string }> {
|
|
327
|
-
const resolved = await resolveAndroidApp(device, app);
|
|
328
|
-
if (resolved.type === 'intent') {
|
|
329
|
-
throw new AppError('INVALID_ARGS', 'reinstall requires a package name, not an intent');
|
|
330
|
-
}
|
|
331
|
-
const result = await runCmd('adb', adbArgs(device, ['uninstall', resolved.value]), { allowFailure: true });
|
|
332
|
-
if (result.exitCode !== 0) {
|
|
333
|
-
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
334
|
-
if (!output.includes('unknown package') && !output.includes('not installed')) {
|
|
335
|
-
throw new AppError('COMMAND_FAILED', `adb uninstall failed for ${resolved.value}`, {
|
|
336
|
-
stdout: result.stdout,
|
|
337
|
-
stderr: result.stderr,
|
|
338
|
-
exitCode: result.exitCode,
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
return { package: resolved.value };
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export async function installAndroidApp(device: DeviceInfo, appPath: string): Promise<void> {
|
|
346
|
-
await runCmd('adb', adbArgs(device, ['install', appPath]));
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
export async function reinstallAndroidApp(
|
|
350
|
-
device: DeviceInfo,
|
|
351
|
-
app: string,
|
|
352
|
-
appPath: string,
|
|
353
|
-
): Promise<{ package: string }> {
|
|
354
|
-
if (!device.booted) {
|
|
355
|
-
await waitForAndroidBoot(device.id);
|
|
356
|
-
}
|
|
357
|
-
const { package: pkg } = await uninstallAndroidApp(device, app);
|
|
358
|
-
await installAndroidApp(device, appPath);
|
|
359
|
-
return { package: pkg };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise<void> {
|
|
363
|
-
await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
export async function swipeAndroid(
|
|
367
|
-
device: DeviceInfo,
|
|
368
|
-
x1: number,
|
|
369
|
-
y1: number,
|
|
370
|
-
x2: number,
|
|
371
|
-
y2: number,
|
|
372
|
-
durationMs = 250,
|
|
373
|
-
): Promise<void> {
|
|
374
|
-
await runCmd(
|
|
375
|
-
'adb',
|
|
376
|
-
adbArgs(device, [
|
|
377
|
-
'shell',
|
|
378
|
-
'input',
|
|
379
|
-
'swipe',
|
|
380
|
-
String(x1),
|
|
381
|
-
String(y1),
|
|
382
|
-
String(x2),
|
|
383
|
-
String(y2),
|
|
384
|
-
String(durationMs),
|
|
385
|
-
]),
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
export async function backAndroid(device: DeviceInfo): Promise<void> {
|
|
390
|
-
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '4']));
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
export async function homeAndroid(device: DeviceInfo): Promise<void> {
|
|
394
|
-
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '3']));
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
export async function appSwitcherAndroid(device: DeviceInfo): Promise<void> {
|
|
398
|
-
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '187']));
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
export async function longPressAndroid(
|
|
402
|
-
device: DeviceInfo,
|
|
403
|
-
x: number,
|
|
404
|
-
y: number,
|
|
405
|
-
durationMs = 800,
|
|
406
|
-
): Promise<void> {
|
|
407
|
-
await runCmd(
|
|
408
|
-
'adb',
|
|
409
|
-
adbArgs(device, [
|
|
410
|
-
'shell',
|
|
411
|
-
'input',
|
|
412
|
-
'swipe',
|
|
413
|
-
String(x),
|
|
414
|
-
String(y),
|
|
415
|
-
String(x),
|
|
416
|
-
String(y),
|
|
417
|
-
String(durationMs),
|
|
418
|
-
]),
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
export async function typeAndroid(device: DeviceInfo, text: string): Promise<void> {
|
|
423
|
-
const encoded = text.replace(/ /g, '%s');
|
|
424
|
-
await runCmd('adb', adbArgs(device, ['shell', 'input', 'text', encoded]));
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
export async function focusAndroid(device: DeviceInfo, x: number, y: number): Promise<void> {
|
|
428
|
-
await pressAndroid(device, x, y);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
export async function fillAndroid(
|
|
432
|
-
device: DeviceInfo,
|
|
433
|
-
x: number,
|
|
434
|
-
y: number,
|
|
435
|
-
text: string,
|
|
436
|
-
): Promise<void> {
|
|
437
|
-
const attempts = [
|
|
438
|
-
{ clearPadding: 12, minClear: 8, maxClear: 48, chunkSize: 4, delayMs: 0 },
|
|
439
|
-
{ clearPadding: 24, minClear: 16, maxClear: 96, chunkSize: 1, delayMs: 15 },
|
|
440
|
-
] as const;
|
|
441
|
-
|
|
442
|
-
await focusAndroid(device, x, y);
|
|
443
|
-
let lastActual: string | null = null;
|
|
444
|
-
|
|
445
|
-
for (const attempt of attempts) {
|
|
446
|
-
const clearCount = clampCount(
|
|
447
|
-
text.length + attempt.clearPadding,
|
|
448
|
-
attempt.minClear,
|
|
449
|
-
attempt.maxClear,
|
|
450
|
-
);
|
|
451
|
-
await clearFocusedText(device, clearCount);
|
|
452
|
-
await typeAndroidChunked(device, text, attempt.chunkSize, attempt.delayMs);
|
|
453
|
-
lastActual = await readInputValueAtPoint(device, x, y);
|
|
454
|
-
if (lastActual === text) return;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
throw new AppError('COMMAND_FAILED', 'Android fill verification failed', {
|
|
458
|
-
expected: text,
|
|
459
|
-
actual: lastActual ?? null,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
export async function scrollAndroid(
|
|
464
|
-
device: DeviceInfo,
|
|
465
|
-
direction: string,
|
|
466
|
-
amount = 0.6,
|
|
467
|
-
): Promise<void> {
|
|
468
|
-
const size = await getAndroidScreenSize(device);
|
|
469
|
-
const { width, height } = size;
|
|
470
|
-
const distanceX = Math.floor(width * amount);
|
|
471
|
-
const distanceY = Math.floor(height * amount);
|
|
472
|
-
|
|
473
|
-
const centerX = Math.floor(width / 2);
|
|
474
|
-
const centerY = Math.floor(height / 2);
|
|
475
|
-
|
|
476
|
-
let x1 = centerX;
|
|
477
|
-
let y1 = centerY;
|
|
478
|
-
let x2 = centerX;
|
|
479
|
-
let y2 = centerY;
|
|
480
|
-
|
|
481
|
-
switch (direction) {
|
|
482
|
-
case 'up':
|
|
483
|
-
// Content moves up -> swipe down.
|
|
484
|
-
y1 = centerY - Math.floor(distanceY / 2);
|
|
485
|
-
y2 = centerY + Math.floor(distanceY / 2);
|
|
486
|
-
break;
|
|
487
|
-
case 'down':
|
|
488
|
-
// Content moves down -> swipe up.
|
|
489
|
-
y1 = centerY + Math.floor(distanceY / 2);
|
|
490
|
-
y2 = centerY - Math.floor(distanceY / 2);
|
|
491
|
-
break;
|
|
492
|
-
case 'left':
|
|
493
|
-
// Content moves left -> swipe right.
|
|
494
|
-
x1 = centerX - Math.floor(distanceX / 2);
|
|
495
|
-
x2 = centerX + Math.floor(distanceX / 2);
|
|
496
|
-
break;
|
|
497
|
-
case 'right':
|
|
498
|
-
// Content moves right -> swipe left.
|
|
499
|
-
x1 = centerX + Math.floor(distanceX / 2);
|
|
500
|
-
x2 = centerX - Math.floor(distanceX / 2);
|
|
501
|
-
break;
|
|
502
|
-
default:
|
|
503
|
-
throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
await runCmd(
|
|
507
|
-
'adb',
|
|
508
|
-
adbArgs(device, [
|
|
509
|
-
'shell',
|
|
510
|
-
'input',
|
|
511
|
-
'swipe',
|
|
512
|
-
String(x1),
|
|
513
|
-
String(y1),
|
|
514
|
-
String(x2),
|
|
515
|
-
String(y2),
|
|
516
|
-
'300',
|
|
517
|
-
]),
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
export async function scrollIntoViewAndroid(device: DeviceInfo, text: string): Promise<void> {
|
|
522
|
-
const maxAttempts = 8;
|
|
523
|
-
for (let i = 0; i < maxAttempts; i += 1) {
|
|
524
|
-
let xml = '';
|
|
525
|
-
try {
|
|
526
|
-
xml = await dumpUiHierarchy(device);
|
|
527
|
-
} catch (err) {
|
|
528
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
529
|
-
throw new AppError('UNSUPPORTED_OPERATION', `uiautomator dump failed: ${message}`);
|
|
530
|
-
}
|
|
531
|
-
if (findBounds(xml, text)) return;
|
|
532
|
-
await scrollAndroid(device, 'down', 0.5);
|
|
533
|
-
}
|
|
534
|
-
throw new AppError(
|
|
535
|
-
'COMMAND_FAILED',
|
|
536
|
-
`Could not find element containing "${text}" after scrolling`,
|
|
537
|
-
);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
export async function screenshotAndroid(device: DeviceInfo, outPath: string): Promise<void> {
|
|
541
|
-
const result = await runCmd('adb', adbArgs(device, ['exec-out', 'screencap', '-p']), {
|
|
542
|
-
binaryStdout: true,
|
|
543
|
-
});
|
|
544
|
-
if (!result.stdoutBuffer) {
|
|
545
|
-
throw new AppError('COMMAND_FAILED', 'Failed to capture screenshot');
|
|
546
|
-
}
|
|
547
|
-
await fs.writeFile(outPath, result.stdoutBuffer);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
export async function setAndroidSetting(
|
|
551
|
-
device: DeviceInfo,
|
|
552
|
-
setting: string,
|
|
553
|
-
state: string,
|
|
554
|
-
): Promise<void> {
|
|
555
|
-
const normalized = setting.toLowerCase();
|
|
556
|
-
const enabled = parseSettingState(state);
|
|
557
|
-
switch (normalized) {
|
|
558
|
-
case 'wifi': {
|
|
559
|
-
await runCmd('adb', adbArgs(device, ['shell', 'svc', 'wifi', enabled ? 'enable' : 'disable']));
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
case 'airplane': {
|
|
563
|
-
const flag = enabled ? '1' : '0';
|
|
564
|
-
const bool = enabled ? 'true' : 'false';
|
|
565
|
-
await runCmd('adb', adbArgs(device, ['shell', 'settings', 'put', 'global', 'airplane_mode_on', flag]));
|
|
566
|
-
await runCmd('adb', adbArgs(device, ['shell', 'am', 'broadcast', '-a', 'android.intent.action.AIRPLANE_MODE', '--ez', 'state', bool]));
|
|
567
|
-
return;
|
|
568
|
-
}
|
|
569
|
-
case 'location': {
|
|
570
|
-
const mode = enabled ? '3' : '0';
|
|
571
|
-
await runCmd('adb', adbArgs(device, ['shell', 'settings', 'put', 'secure', 'location_mode', mode]));
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
default:
|
|
575
|
-
throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
export async function snapshotAndroid(
|
|
580
|
-
device: DeviceInfo,
|
|
581
|
-
options: SnapshotOptions = {},
|
|
582
|
-
): Promise<{
|
|
583
|
-
nodes: RawSnapshotNode[];
|
|
584
|
-
truncated?: boolean;
|
|
585
|
-
}> {
|
|
586
|
-
const xml = await dumpUiHierarchy(device);
|
|
587
|
-
return parseUiHierarchy(xml, 800, options);
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
export async function ensureAdb(): Promise<void> {
|
|
591
|
-
const adbAvailable = await whichCmd('adb');
|
|
592
|
-
if (!adbAvailable) throw new AppError('TOOL_MISSING', 'adb not found in PATH');
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
async function getAndroidScreenSize(
|
|
596
|
-
device: DeviceInfo,
|
|
597
|
-
): Promise<{ width: number; height: number }> {
|
|
598
|
-
const result = await runCmd('adb', adbArgs(device, ['shell', 'wm', 'size']));
|
|
599
|
-
const match = result.stdout.match(/Physical size:\s*(\d+)x(\d+)/);
|
|
600
|
-
if (!match) throw new AppError('COMMAND_FAILED', 'Unable to read screen size');
|
|
601
|
-
return { width: Number(match[1]), height: Number(match[2]) };
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
async function dumpUiHierarchy(device: DeviceInfo): Promise<string> {
|
|
605
|
-
return withRetry(() => dumpUiHierarchyOnce(device), {
|
|
606
|
-
shouldRetry: isRetryableAdbError,
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
async function dumpUiHierarchyOnce(device: DeviceInfo): Promise<string> {
|
|
611
|
-
// Preferred: stream XML directly to stdout, avoiding file I/O race conditions.
|
|
612
|
-
const streamed = await runCmd(
|
|
613
|
-
'adb',
|
|
614
|
-
adbArgs(device, ['exec-out', 'uiautomator', 'dump', '/dev/tty']),
|
|
615
|
-
{ allowFailure: true },
|
|
616
|
-
);
|
|
617
|
-
if (streamed.exitCode === 0) {
|
|
618
|
-
const fromStream = extractUiDumpXml(streamed.stdout, streamed.stderr);
|
|
619
|
-
if (fromStream) return fromStream;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// Fallback: dump to file and read back.
|
|
623
|
-
// If `cat` fails with "no such file", the outer withRetry (via isRetryableAdbError) handles it.
|
|
624
|
-
const dumpPath = '/sdcard/window_dump.xml';
|
|
625
|
-
const dumpResult = await runCmd(
|
|
626
|
-
'adb',
|
|
627
|
-
adbArgs(device, ['shell', 'uiautomator', 'dump', dumpPath]),
|
|
628
|
-
);
|
|
629
|
-
const actualPath = resolveDumpPath(dumpPath, dumpResult.stdout, dumpResult.stderr);
|
|
630
|
-
|
|
631
|
-
const result = await runCmd('adb', adbArgs(device, ['shell', 'cat', actualPath]));
|
|
632
|
-
const xml = extractUiDumpXml(result.stdout, result.stderr);
|
|
633
|
-
if (!xml) {
|
|
634
|
-
throw new AppError('COMMAND_FAILED', 'uiautomator dump did not return XML', {
|
|
635
|
-
stdout: result.stdout,
|
|
636
|
-
stderr: result.stderr,
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
return xml;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
function resolveDumpPath(defaultPath: string, stdout: string, stderr: string): string {
|
|
643
|
-
const text = `${stdout}\n${stderr}`;
|
|
644
|
-
const match = /dumped to:\s*(\S+)/i.exec(text);
|
|
645
|
-
return match?.[1] ?? defaultPath;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
function extractUiDumpXml(stdout: string, stderr: string): string | null {
|
|
649
|
-
const text = `${stdout}\n${stderr}`;
|
|
650
|
-
const start = text.indexOf('<?xml');
|
|
651
|
-
const hierarchyStart = start >= 0 ? start : text.indexOf('<hierarchy');
|
|
652
|
-
if (hierarchyStart < 0) return null;
|
|
653
|
-
const end = text.lastIndexOf('</hierarchy>');
|
|
654
|
-
if (end < 0 || end < hierarchyStart) return null;
|
|
655
|
-
const xml = text.slice(hierarchyStart, end + '</hierarchy>'.length).trim();
|
|
656
|
-
return xml.length > 0 ? xml : null;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function isRetryableAdbError(err: unknown): boolean {
|
|
660
|
-
if (!(err instanceof AppError)) return false;
|
|
661
|
-
if (err.code !== 'COMMAND_FAILED') return false;
|
|
662
|
-
const stderr = `${(err.details as any)?.stderr ?? ''}`.toLowerCase();
|
|
663
|
-
if (stderr.includes('device offline')) return true;
|
|
664
|
-
if (stderr.includes('device not found')) return true;
|
|
665
|
-
if (stderr.includes('transport error')) return true;
|
|
666
|
-
if (stderr.includes('connection reset')) return true;
|
|
667
|
-
if (stderr.includes('broken pipe')) return true;
|
|
668
|
-
if (stderr.includes('timed out')) return true;
|
|
669
|
-
if (stderr.includes('no such file or directory')) return true;
|
|
670
|
-
return false;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
function parseSettingState(state: string): boolean {
|
|
674
|
-
const normalized = state.toLowerCase();
|
|
675
|
-
if (normalized === 'on' || normalized === 'true' || normalized === '1') return true;
|
|
676
|
-
if (normalized === 'off' || normalized === 'false' || normalized === '0') return false;
|
|
677
|
-
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
async function typeAndroidChunked(
|
|
681
|
-
device: DeviceInfo,
|
|
682
|
-
text: string,
|
|
683
|
-
chunkSize: number,
|
|
684
|
-
delayMs: number,
|
|
685
|
-
): Promise<void> {
|
|
686
|
-
const size = Math.max(1, Math.floor(chunkSize));
|
|
687
|
-
for (let i = 0; i < text.length; i += size) {
|
|
688
|
-
const chunk = text.slice(i, i + size);
|
|
689
|
-
await typeAndroid(device, chunk);
|
|
690
|
-
if (delayMs > 0 && i + size < text.length) {
|
|
691
|
-
await sleep(delayMs);
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
async function clearFocusedText(device: DeviceInfo, count: number): Promise<void> {
|
|
697
|
-
const deletes = Math.max(0, count);
|
|
698
|
-
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', 'KEYCODE_MOVE_END']), {
|
|
699
|
-
allowFailure: true,
|
|
700
|
-
});
|
|
701
|
-
const batchSize = 24;
|
|
702
|
-
for (let i = 0; i < deletes; i += batchSize) {
|
|
703
|
-
const size = Math.min(batchSize, deletes - i);
|
|
704
|
-
await runCmd(
|
|
705
|
-
'adb',
|
|
706
|
-
adbArgs(device, ['shell', 'input', 'keyevent', ...Array(size).fill('KEYCODE_DEL')]),
|
|
707
|
-
{
|
|
708
|
-
allowFailure: true,
|
|
709
|
-
},
|
|
710
|
-
);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
async function readInputValueAtPoint(
|
|
715
|
-
device: DeviceInfo,
|
|
716
|
-
x: number,
|
|
717
|
-
y: number,
|
|
718
|
-
): Promise<string | null> {
|
|
719
|
-
const xml = await dumpUiHierarchy(device);
|
|
720
|
-
const nodeRegex = /<node\b[^>]*>/g;
|
|
721
|
-
let match: RegExpExecArray | null;
|
|
722
|
-
let focusedEdit: { text: string; area: number } | null = null;
|
|
723
|
-
let editAtPoint: { text: string; area: number } | null = null;
|
|
724
|
-
let anyAtPoint: { text: string; area: number } | null = null;
|
|
725
|
-
|
|
726
|
-
while ((match = nodeRegex.exec(xml)) !== null) {
|
|
727
|
-
const node = match[0];
|
|
728
|
-
const attrs = readNodeAttributes(node);
|
|
729
|
-
const rect = parseBounds(attrs.bounds);
|
|
730
|
-
if (!rect) continue;
|
|
731
|
-
const className = attrs.className ?? '';
|
|
732
|
-
const text = decodeXmlEntities(attrs.text ?? '');
|
|
733
|
-
const focused = attrs.focused ?? false;
|
|
734
|
-
if (!text) continue;
|
|
735
|
-
const area = Math.max(1, rect.width * rect.height);
|
|
736
|
-
const containsPoint =
|
|
737
|
-
x >= rect.x &&
|
|
738
|
-
x <= rect.x + rect.width &&
|
|
739
|
-
y >= rect.y &&
|
|
740
|
-
y <= rect.y + rect.height;
|
|
741
|
-
|
|
742
|
-
if (focused && isEditTextClass(className)) {
|
|
743
|
-
if (!focusedEdit || area <= focusedEdit.area) {
|
|
744
|
-
focusedEdit = { text, area };
|
|
745
|
-
}
|
|
746
|
-
continue;
|
|
747
|
-
}
|
|
748
|
-
if (containsPoint && isEditTextClass(className)) {
|
|
749
|
-
if (!editAtPoint || area <= editAtPoint.area) {
|
|
750
|
-
editAtPoint = { text, area };
|
|
751
|
-
}
|
|
752
|
-
continue;
|
|
753
|
-
}
|
|
754
|
-
if (containsPoint) {
|
|
755
|
-
if (!anyAtPoint || area <= anyAtPoint.area) {
|
|
756
|
-
anyAtPoint = { text, area };
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
return focusedEdit?.text ?? editAtPoint?.text ?? anyAtPoint?.text ?? null;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function isEditTextClass(className: string): boolean {
|
|
765
|
-
const lower = className.toLowerCase();
|
|
766
|
-
return lower.includes('edittext') || lower.includes('textfield');
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
function decodeXmlEntities(value: string): string {
|
|
770
|
-
return value
|
|
771
|
-
.replace(/"/g, '"')
|
|
772
|
-
.replace(/'/g, "'")
|
|
773
|
-
.replace(/</g, '<')
|
|
774
|
-
.replace(/>/g, '>')
|
|
775
|
-
.replace(/&/g, '&');
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
async function sleep(ms: number): Promise<void> {
|
|
779
|
-
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function clampCount(value: number, min: number, max: number): number {
|
|
783
|
-
return Math.max(min, Math.min(max, value));
|
|
784
|
-
}
|