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.
Files changed (91) hide show
  1. package/README.md +2 -9
  2. package/dist/src/797.js +1 -1
  3. package/dist/src/bin.js +5 -5
  4. package/dist/src/daemon.js +16 -20
  5. package/package.json +7 -9
  6. package/skills/agent-device/SKILL.md +3 -6
  7. package/skills/agent-device/references/permissions.md +3 -15
  8. package/skills/agent-device/references/snapshot-refs.md +1 -4
  9. package/dist/bin/axsnapshot +0 -0
  10. package/ios-runner/AXSnapshot/Package.swift +0 -18
  11. package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
  12. package/src/__tests__/cli-close.test.ts +0 -155
  13. package/src/__tests__/cli-help.test.ts +0 -102
  14. package/src/bin.ts +0 -3
  15. package/src/cli.ts +0 -305
  16. package/src/core/__tests__/capabilities.test.ts +0 -75
  17. package/src/core/__tests__/dispatch-open.test.ts +0 -25
  18. package/src/core/__tests__/open-target.test.ts +0 -55
  19. package/src/core/capabilities.ts +0 -57
  20. package/src/core/dispatch.ts +0 -382
  21. package/src/core/open-target.ts +0 -27
  22. package/src/daemon/__tests__/device-ready.test.ts +0 -52
  23. package/src/daemon/__tests__/is-predicates.test.ts +0 -68
  24. package/src/daemon/__tests__/selectors.test.ts +0 -261
  25. package/src/daemon/__tests__/session-routing.test.ts +0 -108
  26. package/src/daemon/__tests__/session-selector.test.ts +0 -64
  27. package/src/daemon/__tests__/session-store.test.ts +0 -142
  28. package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
  29. package/src/daemon/action-utils.ts +0 -29
  30. package/src/daemon/context.ts +0 -48
  31. package/src/daemon/device-ready.ts +0 -155
  32. package/src/daemon/handlers/__tests__/find.test.ts +0 -99
  33. package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
  34. package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
  35. package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
  36. package/src/daemon/handlers/__tests__/session.test.ts +0 -820
  37. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
  38. package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
  39. package/src/daemon/handlers/find.ts +0 -324
  40. package/src/daemon/handlers/interaction.ts +0 -550
  41. package/src/daemon/handlers/parse-utils.ts +0 -8
  42. package/src/daemon/handlers/record-trace.ts +0 -154
  43. package/src/daemon/handlers/session.ts +0 -1137
  44. package/src/daemon/handlers/snapshot.ts +0 -439
  45. package/src/daemon/is-predicates.ts +0 -46
  46. package/src/daemon/selectors.ts +0 -540
  47. package/src/daemon/session-routing.ts +0 -22
  48. package/src/daemon/session-selector.ts +0 -39
  49. package/src/daemon/session-store.ts +0 -296
  50. package/src/daemon/snapshot-processing.ts +0 -131
  51. package/src/daemon/types.ts +0 -56
  52. package/src/daemon-client.ts +0 -272
  53. package/src/daemon.ts +0 -295
  54. package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
  55. package/src/platforms/android/__tests__/index.test.ts +0 -274
  56. package/src/platforms/android/devices.ts +0 -196
  57. package/src/platforms/android/index.ts +0 -784
  58. package/src/platforms/android/ui-hierarchy.ts +0 -312
  59. package/src/platforms/boot-diagnostics.ts +0 -128
  60. package/src/platforms/ios/__tests__/index.test.ts +0 -312
  61. package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
  62. package/src/platforms/ios/apps.ts +0 -358
  63. package/src/platforms/ios/ax-snapshot.ts +0 -207
  64. package/src/platforms/ios/config.ts +0 -28
  65. package/src/platforms/ios/devicectl.ts +0 -134
  66. package/src/platforms/ios/devices.ts +0 -100
  67. package/src/platforms/ios/index.ts +0 -20
  68. package/src/platforms/ios/runner-client.ts +0 -994
  69. package/src/platforms/ios/simulator.ts +0 -164
  70. package/src/utils/__tests__/args.test.ts +0 -239
  71. package/src/utils/__tests__/daemon-client.test.ts +0 -95
  72. package/src/utils/__tests__/exec.test.ts +0 -16
  73. package/src/utils/__tests__/finders.test.ts +0 -34
  74. package/src/utils/__tests__/keyed-lock.test.ts +0 -55
  75. package/src/utils/__tests__/process-identity.test.ts +0 -33
  76. package/src/utils/__tests__/retry.test.ts +0 -44
  77. package/src/utils/args.ts +0 -239
  78. package/src/utils/command-schema.ts +0 -622
  79. package/src/utils/device.ts +0 -84
  80. package/src/utils/errors.ts +0 -35
  81. package/src/utils/exec.ts +0 -339
  82. package/src/utils/finders.ts +0 -101
  83. package/src/utils/interactive.ts +0 -4
  84. package/src/utils/interactors.ts +0 -173
  85. package/src/utils/keyed-lock.ts +0 -14
  86. package/src/utils/output.ts +0 -204
  87. package/src/utils/process-identity.ts +0 -100
  88. package/src/utils/retry.ts +0 -180
  89. package/src/utils/snapshot.ts +0 -64
  90. package/src/utils/timeouts.ts +0 -9
  91. 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(/&quot;/g, '"')
772
- .replace(/&apos;/g, "'")
773
- .replace(/&lt;/g, '<')
774
- .replace(/&gt;/g, '>')
775
- .replace(/&amp;/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
- }