agent-device 0.2.1 → 0.2.3
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 +17 -1
- package/dist/src/bin.js +27 -24
- package/dist/src/daemon.js +7 -7
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +98 -12
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +18 -8
- package/skills/agent-device/references/session-management.md +1 -1
- package/src/cli.ts +1 -0
- package/src/core/dispatch.ts +22 -2
- package/src/daemon.ts +54 -36
- package/src/platforms/android/index.ts +265 -17
- package/src/platforms/ios/runner-client.ts +7 -3
- package/src/utils/args.ts +5 -0
- package/src/utils/interactors.ts +2 -2
- package/src/utils/output.ts +84 -26
|
@@ -148,25 +148,58 @@ function parseAndroidFocus(text: string): { package?: string; activity?: string
|
|
|
148
148
|
return null;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
export async function openAndroidApp(
|
|
151
|
+
export async function openAndroidApp(
|
|
152
|
+
device: DeviceInfo,
|
|
153
|
+
app: string,
|
|
154
|
+
activity?: string,
|
|
155
|
+
): Promise<void> {
|
|
152
156
|
if (!device.booted) {
|
|
153
157
|
await waitForAndroidBoot(device.id);
|
|
154
158
|
}
|
|
155
159
|
const resolved = await resolveAndroidApp(device, app);
|
|
156
160
|
if (resolved.type === 'intent') {
|
|
161
|
+
if (activity) {
|
|
162
|
+
throw new AppError('INVALID_ARGS', 'Activity override requires a package name, not an intent');
|
|
163
|
+
}
|
|
157
164
|
await runCmd('adb', adbArgs(device, ['shell', 'am', 'start', '-a', resolved.value]));
|
|
158
165
|
return;
|
|
159
166
|
}
|
|
167
|
+
if (activity) {
|
|
168
|
+
const component = activity.includes('/')
|
|
169
|
+
? activity
|
|
170
|
+
: `${resolved.value}/${activity.startsWith('.') ? activity : `.${activity}`}`;
|
|
171
|
+
await runCmd(
|
|
172
|
+
'adb',
|
|
173
|
+
adbArgs(device, [
|
|
174
|
+
'shell',
|
|
175
|
+
'am',
|
|
176
|
+
'start',
|
|
177
|
+
'-a',
|
|
178
|
+
'android.intent.action.MAIN',
|
|
179
|
+
'-c',
|
|
180
|
+
'android.intent.category.DEFAULT',
|
|
181
|
+
'-c',
|
|
182
|
+
'android.intent.category.LAUNCHER',
|
|
183
|
+
'-n',
|
|
184
|
+
component,
|
|
185
|
+
]),
|
|
186
|
+
);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
160
189
|
await runCmd(
|
|
161
190
|
'adb',
|
|
162
191
|
adbArgs(device, [
|
|
163
192
|
'shell',
|
|
164
|
-
'
|
|
165
|
-
'
|
|
166
|
-
|
|
193
|
+
'am',
|
|
194
|
+
'start',
|
|
195
|
+
'-a',
|
|
196
|
+
'android.intent.action.MAIN',
|
|
197
|
+
'-c',
|
|
198
|
+
'android.intent.category.DEFAULT',
|
|
167
199
|
'-c',
|
|
168
200
|
'android.intent.category.LAUNCHER',
|
|
169
|
-
'
|
|
201
|
+
'-p',
|
|
202
|
+
resolved.value,
|
|
170
203
|
]),
|
|
171
204
|
);
|
|
172
205
|
}
|
|
@@ -242,8 +275,30 @@ export async function fillAndroid(
|
|
|
242
275
|
y: number,
|
|
243
276
|
text: string,
|
|
244
277
|
): Promise<void> {
|
|
278
|
+
const attempts = [
|
|
279
|
+
{ clearPadding: 12, minClear: 8, maxClear: 48, chunkSize: 4, delayMs: 0 },
|
|
280
|
+
{ clearPadding: 24, minClear: 16, maxClear: 96, chunkSize: 1, delayMs: 15 },
|
|
281
|
+
] as const;
|
|
282
|
+
|
|
245
283
|
await focusAndroid(device, x, y);
|
|
246
|
-
|
|
284
|
+
let lastActual: string | null = null;
|
|
285
|
+
|
|
286
|
+
for (const attempt of attempts) {
|
|
287
|
+
const clearCount = clampCount(
|
|
288
|
+
text.length + attempt.clearPadding,
|
|
289
|
+
attempt.minClear,
|
|
290
|
+
attempt.maxClear,
|
|
291
|
+
);
|
|
292
|
+
await clearFocusedText(device, clearCount);
|
|
293
|
+
await typeAndroidChunked(device, text, attempt.chunkSize, attempt.delayMs);
|
|
294
|
+
lastActual = await readInputValueAtPoint(device, x, y);
|
|
295
|
+
if (lastActual === text) return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
throw new AppError('COMMAND_FAILED', 'Android fill verification failed', {
|
|
299
|
+
expected: text,
|
|
300
|
+
actual: lastActual ?? null,
|
|
301
|
+
});
|
|
247
302
|
}
|
|
248
303
|
|
|
249
304
|
export async function scrollAndroid(
|
|
@@ -422,6 +477,112 @@ function parseSettingState(state: string): boolean {
|
|
|
422
477
|
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
|
|
423
478
|
}
|
|
424
479
|
|
|
480
|
+
async function typeAndroidChunked(
|
|
481
|
+
device: DeviceInfo,
|
|
482
|
+
text: string,
|
|
483
|
+
chunkSize: number,
|
|
484
|
+
delayMs: number,
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
const size = Math.max(1, Math.floor(chunkSize));
|
|
487
|
+
for (let i = 0; i < text.length; i += size) {
|
|
488
|
+
const chunk = text.slice(i, i + size);
|
|
489
|
+
await typeAndroid(device, chunk);
|
|
490
|
+
if (delayMs > 0 && i + size < text.length) {
|
|
491
|
+
await sleep(delayMs);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function clearFocusedText(device: DeviceInfo, count: number): Promise<void> {
|
|
497
|
+
const deletes = Math.max(0, count);
|
|
498
|
+
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', 'KEYCODE_MOVE_END']), {
|
|
499
|
+
allowFailure: true,
|
|
500
|
+
});
|
|
501
|
+
const batchSize = 24;
|
|
502
|
+
for (let i = 0; i < deletes; i += batchSize) {
|
|
503
|
+
const size = Math.min(batchSize, deletes - i);
|
|
504
|
+
await runCmd(
|
|
505
|
+
'adb',
|
|
506
|
+
adbArgs(device, ['shell', 'input', 'keyevent', ...Array(size).fill('KEYCODE_DEL')]),
|
|
507
|
+
{
|
|
508
|
+
allowFailure: true,
|
|
509
|
+
},
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function readInputValueAtPoint(
|
|
515
|
+
device: DeviceInfo,
|
|
516
|
+
x: number,
|
|
517
|
+
y: number,
|
|
518
|
+
): Promise<string | null> {
|
|
519
|
+
const xml = await dumpUiHierarchy(device);
|
|
520
|
+
const nodeRegex = /<node\b[^>]*>/g;
|
|
521
|
+
let match: RegExpExecArray | null;
|
|
522
|
+
let focusedEdit: { text: string; area: number } | null = null;
|
|
523
|
+
let editAtPoint: { text: string; area: number } | null = null;
|
|
524
|
+
let anyAtPoint: { text: string; area: number } | null = null;
|
|
525
|
+
|
|
526
|
+
while ((match = nodeRegex.exec(xml)) !== null) {
|
|
527
|
+
const node = match[0];
|
|
528
|
+
const attrs = readNodeAttributes(node);
|
|
529
|
+
const rect = parseBounds(attrs.bounds);
|
|
530
|
+
if (!rect) continue;
|
|
531
|
+
const className = attrs.className ?? '';
|
|
532
|
+
const text = decodeXmlEntities(attrs.text ?? '');
|
|
533
|
+
const focused = attrs.focused ?? false;
|
|
534
|
+
if (!text) continue;
|
|
535
|
+
const area = Math.max(1, rect.width * rect.height);
|
|
536
|
+
const containsPoint =
|
|
537
|
+
x >= rect.x &&
|
|
538
|
+
x <= rect.x + rect.width &&
|
|
539
|
+
y >= rect.y &&
|
|
540
|
+
y <= rect.y + rect.height;
|
|
541
|
+
|
|
542
|
+
if (focused && isEditTextClass(className)) {
|
|
543
|
+
if (!focusedEdit || area <= focusedEdit.area) {
|
|
544
|
+
focusedEdit = { text, area };
|
|
545
|
+
}
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (containsPoint && isEditTextClass(className)) {
|
|
549
|
+
if (!editAtPoint || area <= editAtPoint.area) {
|
|
550
|
+
editAtPoint = { text, area };
|
|
551
|
+
}
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (containsPoint) {
|
|
555
|
+
if (!anyAtPoint || area <= anyAtPoint.area) {
|
|
556
|
+
anyAtPoint = { text, area };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return focusedEdit?.text ?? editAtPoint?.text ?? anyAtPoint?.text ?? null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function isEditTextClass(className: string): boolean {
|
|
565
|
+
const lower = className.toLowerCase();
|
|
566
|
+
return lower.includes('edittext') || lower.includes('textfield');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function decodeXmlEntities(value: string): string {
|
|
570
|
+
return value
|
|
571
|
+
.replace(/"/g, '"')
|
|
572
|
+
.replace(/'/g, "'")
|
|
573
|
+
.replace(/</g, '<')
|
|
574
|
+
.replace(/>/g, '>')
|
|
575
|
+
.replace(/&/g, '&');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function sleep(ms: number): Promise<void> {
|
|
579
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function clampCount(value: number, min: number, max: number): number {
|
|
583
|
+
return Math.max(min, Math.min(max, value));
|
|
584
|
+
}
|
|
585
|
+
|
|
425
586
|
function findBounds(xml: string, query: string): { x: number; y: number } | null {
|
|
426
587
|
const q = query.toLowerCase();
|
|
427
588
|
const nodeRegex = /<node[^>]+>/g;
|
|
@@ -460,17 +621,47 @@ function parseUiHierarchy(
|
|
|
460
621
|
const scopedRoot = options.scope ? findScopeNode(tree, options.scope) : null;
|
|
461
622
|
const roots = scopedRoot ? [scopedRoot] : tree.children;
|
|
462
623
|
|
|
463
|
-
const
|
|
624
|
+
const interactiveDescendantMemo = new Map<AndroidNode, boolean>();
|
|
625
|
+
const hasInteractiveDescendant = (node: AndroidNode): boolean => {
|
|
626
|
+
const cached = interactiveDescendantMemo.get(node);
|
|
627
|
+
if (cached !== undefined) return cached;
|
|
628
|
+
for (const child of node.children) {
|
|
629
|
+
if (child.hittable || hasInteractiveDescendant(child)) {
|
|
630
|
+
interactiveDescendantMemo.set(node, true);
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
interactiveDescendantMemo.set(node, false);
|
|
635
|
+
return false;
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const walk = (
|
|
639
|
+
node: AndroidNode,
|
|
640
|
+
depth: number,
|
|
641
|
+
parentIndex?: number,
|
|
642
|
+
ancestorHittable: boolean = false,
|
|
643
|
+
ancestorCollection: boolean = false,
|
|
644
|
+
) => {
|
|
464
645
|
if (nodes.length >= maxNodes) {
|
|
465
646
|
truncated = true;
|
|
466
647
|
return;
|
|
467
648
|
}
|
|
468
649
|
if (depth > maxDepth) return;
|
|
469
650
|
|
|
470
|
-
const include = options.raw
|
|
651
|
+
const include = options.raw
|
|
652
|
+
? true
|
|
653
|
+
: shouldIncludeAndroidNode(
|
|
654
|
+
node,
|
|
655
|
+
options,
|
|
656
|
+
ancestorHittable,
|
|
657
|
+
hasInteractiveDescendant(node),
|
|
658
|
+
ancestorCollection,
|
|
659
|
+
);
|
|
660
|
+
let currentIndex = parentIndex;
|
|
471
661
|
if (include) {
|
|
662
|
+
currentIndex = nodes.length;
|
|
472
663
|
nodes.push({
|
|
473
|
-
index:
|
|
664
|
+
index: currentIndex,
|
|
474
665
|
type: node.type ?? undefined,
|
|
475
666
|
label: node.label ?? undefined,
|
|
476
667
|
value: node.value ?? undefined,
|
|
@@ -479,17 +670,19 @@ function parseUiHierarchy(
|
|
|
479
670
|
enabled: node.enabled,
|
|
480
671
|
hittable: node.hittable,
|
|
481
672
|
depth,
|
|
482
|
-
parentIndex
|
|
673
|
+
parentIndex,
|
|
483
674
|
});
|
|
484
675
|
}
|
|
676
|
+
const nextAncestorHittable = ancestorHittable || Boolean(node.hittable);
|
|
677
|
+
const nextAncestorCollection = ancestorCollection || isCollectionContainerType(node.type);
|
|
485
678
|
for (const child of node.children) {
|
|
486
|
-
walk(child, depth + 1);
|
|
679
|
+
walk(child, depth + 1, currentIndex, nextAncestorHittable, nextAncestorCollection);
|
|
487
680
|
if (truncated) return;
|
|
488
681
|
}
|
|
489
682
|
};
|
|
490
683
|
|
|
491
684
|
for (const root of roots) {
|
|
492
|
-
walk(root, 0);
|
|
685
|
+
walk(root, 0, undefined, false, false);
|
|
493
686
|
if (truncated) break;
|
|
494
687
|
}
|
|
495
688
|
|
|
@@ -505,6 +698,7 @@ function readNodeAttributes(node: string): {
|
|
|
505
698
|
clickable?: boolean;
|
|
506
699
|
enabled?: boolean;
|
|
507
700
|
focusable?: boolean;
|
|
701
|
+
focused?: boolean;
|
|
508
702
|
} {
|
|
509
703
|
const getAttr = (name: string): string | null => {
|
|
510
704
|
const regex = new RegExp(`${name}="([^"]*)"`);
|
|
@@ -525,6 +719,7 @@ function readNodeAttributes(node: string): {
|
|
|
525
719
|
clickable: boolAttr('clickable'),
|
|
526
720
|
enabled: boolAttr('enabled'),
|
|
527
721
|
focusable: boolAttr('focusable'),
|
|
722
|
+
focused: boolAttr('focused'),
|
|
528
723
|
};
|
|
529
724
|
}
|
|
530
725
|
|
|
@@ -595,18 +790,71 @@ function parseUiHierarchyTree(xml: string): AndroidNode {
|
|
|
595
790
|
return root;
|
|
596
791
|
}
|
|
597
792
|
|
|
598
|
-
function shouldIncludeAndroidNode(
|
|
793
|
+
function shouldIncludeAndroidNode(
|
|
794
|
+
node: AndroidNode,
|
|
795
|
+
options: SnapshotOptions,
|
|
796
|
+
ancestorHittable: boolean,
|
|
797
|
+
descendantHittable: boolean,
|
|
798
|
+
ancestorCollection: boolean,
|
|
799
|
+
): boolean {
|
|
800
|
+
const type = normalizeAndroidType(node.type);
|
|
801
|
+
const hasText = Boolean(node.label && node.label.trim().length > 0);
|
|
802
|
+
const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
|
|
803
|
+
const hasMeaningfulText = hasText && !isGenericAndroidId(node.label ?? '');
|
|
804
|
+
const hasMeaningfulId = hasId && !isGenericAndroidId(node.identifier ?? '');
|
|
805
|
+
const isStructural = isStructuralAndroidType(type);
|
|
806
|
+
const isVisual = type === 'imageview' || type === 'imagebutton';
|
|
599
807
|
if (options.interactiveOnly) {
|
|
600
|
-
|
|
808
|
+
if (node.hittable) return true;
|
|
809
|
+
// Keep text proxies for tappable rows while dropping structural noise.
|
|
810
|
+
const proxyCandidate = hasMeaningfulText || hasMeaningfulId;
|
|
811
|
+
if (!proxyCandidate) return false;
|
|
812
|
+
if (isVisual) return false;
|
|
813
|
+
if (isStructural && !ancestorCollection) return false;
|
|
814
|
+
return ancestorHittable || descendantHittable || ancestorCollection;
|
|
601
815
|
}
|
|
602
816
|
if (options.compact) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
817
|
+
return hasMeaningfulText || hasMeaningfulId || Boolean(node.hittable);
|
|
818
|
+
}
|
|
819
|
+
if (isStructural || isVisual) {
|
|
820
|
+
if (node.hittable) return true;
|
|
821
|
+
if (hasMeaningfulText) return true;
|
|
822
|
+
if (hasMeaningfulId && descendantHittable) return true;
|
|
823
|
+
return descendantHittable;
|
|
606
824
|
}
|
|
607
825
|
return true;
|
|
608
826
|
}
|
|
609
827
|
|
|
828
|
+
function isCollectionContainerType(type: string | null): boolean {
|
|
829
|
+
if (!type) return false;
|
|
830
|
+
const normalized = normalizeAndroidType(type);
|
|
831
|
+
return (
|
|
832
|
+
normalized.includes('recyclerview') ||
|
|
833
|
+
normalized.includes('listview') ||
|
|
834
|
+
normalized.includes('gridview')
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function normalizeAndroidType(type: string | null): string {
|
|
839
|
+
if (!type) return '';
|
|
840
|
+
return type.toLowerCase();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function isStructuralAndroidType(type: string): boolean {
|
|
844
|
+
const short = type.split('.').pop() ?? type;
|
|
845
|
+
return (
|
|
846
|
+
short.includes('layout') ||
|
|
847
|
+
short === 'viewgroup' ||
|
|
848
|
+
short === 'view'
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function isGenericAndroidId(value: string): boolean {
|
|
853
|
+
const trimmed = value.trim();
|
|
854
|
+
if (!trimmed) return false;
|
|
855
|
+
return /^[\w.]+:id\/[\w.-]+$/i.test(trimmed);
|
|
856
|
+
}
|
|
857
|
+
|
|
610
858
|
function findScopeNode(root: AndroidNode, scope: string): AndroidNode | null {
|
|
611
859
|
const query = scope.toLowerCase();
|
|
612
860
|
const stack: AndroidNode[] = [...root.children];
|
|
@@ -20,6 +20,7 @@ export type RunnerCommand = {
|
|
|
20
20
|
| 'home'
|
|
21
21
|
| 'appSwitcher'
|
|
22
22
|
| 'alert'
|
|
23
|
+
| 'pinch'
|
|
23
24
|
| 'shutdown';
|
|
24
25
|
appBundleId?: string;
|
|
25
26
|
text?: string;
|
|
@@ -27,11 +28,13 @@ export type RunnerCommand = {
|
|
|
27
28
|
x?: number;
|
|
28
29
|
y?: number;
|
|
29
30
|
direction?: 'up' | 'down' | 'left' | 'right';
|
|
31
|
+
scale?: number;
|
|
30
32
|
interactiveOnly?: boolean;
|
|
31
33
|
compact?: boolean;
|
|
32
34
|
depth?: number;
|
|
33
35
|
scope?: string;
|
|
34
36
|
raw?: boolean;
|
|
37
|
+
clearFirst?: boolean;
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
export type RunnerSession = {
|
|
@@ -173,10 +176,9 @@ async function ensureRunnerSession(
|
|
|
173
176
|
await ensureBooted(device.id);
|
|
174
177
|
const xctestrun = await ensureXctestrun(device.id, options);
|
|
175
178
|
const port = await getFreePort();
|
|
176
|
-
const runnerTimeout = process.env.AGENT_DEVICE_RUNNER_TIMEOUT ?? '0';
|
|
177
179
|
const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv(
|
|
178
180
|
xctestrun,
|
|
179
|
-
{ AGENT_DEVICE_RUNNER_PORT: String(port)
|
|
181
|
+
{ AGENT_DEVICE_RUNNER_PORT: String(port) },
|
|
180
182
|
`session-${device.id}-${port}`,
|
|
181
183
|
);
|
|
182
184
|
const testPromise = runCmdStreaming(
|
|
@@ -187,6 +189,8 @@ async function ensureRunnerSession(
|
|
|
187
189
|
'AgentDeviceRunnerUITests/RunnerTests/testCommand',
|
|
188
190
|
'-parallel-testing-enabled',
|
|
189
191
|
'NO',
|
|
192
|
+
'-test-timeouts-enabled',
|
|
193
|
+
'NO',
|
|
190
194
|
'-maximum-concurrent-test-simulator-destinations',
|
|
191
195
|
'1',
|
|
192
196
|
'-xctestrun',
|
|
@@ -202,7 +206,7 @@ async function ensureRunnerSession(
|
|
|
202
206
|
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
203
207
|
},
|
|
204
208
|
allowFailure: true,
|
|
205
|
-
env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port)
|
|
209
|
+
env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
|
|
206
210
|
},
|
|
207
211
|
);
|
|
208
212
|
|
package/src/utils/args.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type ParsedArgs = {
|
|
|
20
20
|
snapshotBackend?: 'ax' | 'xctest';
|
|
21
21
|
appsFilter?: 'launchable' | 'user-installed' | 'all';
|
|
22
22
|
appsMetadata?: boolean;
|
|
23
|
+
activity?: string;
|
|
23
24
|
noRecord?: boolean;
|
|
24
25
|
recordJson?: boolean;
|
|
25
26
|
help: boolean;
|
|
@@ -125,6 +126,9 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
125
126
|
case '--session':
|
|
126
127
|
flags.session = value;
|
|
127
128
|
break;
|
|
129
|
+
case '--activity':
|
|
130
|
+
flags.activity = value;
|
|
131
|
+
break;
|
|
128
132
|
default:
|
|
129
133
|
throw new AppError('INVALID_ARGS', `Unknown flag: ${key}`);
|
|
130
134
|
}
|
|
@@ -208,6 +212,7 @@ Flags:
|
|
|
208
212
|
--device <name> Device name to target
|
|
209
213
|
--udid <udid> iOS device UDID
|
|
210
214
|
--serial <serial> Android device serial
|
|
215
|
+
--activity <component> Android activity to launch (package/Activity)
|
|
211
216
|
--out <path> Output path for screenshots
|
|
212
217
|
--session <name> Named session
|
|
213
218
|
--verbose Stream daemon/runner logs
|
package/src/utils/interactors.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from '../platforms/ios/index.ts';
|
|
29
29
|
|
|
30
30
|
export type Interactor = {
|
|
31
|
-
open(app: string): Promise<void>;
|
|
31
|
+
open(app: string, options?: { activity?: string }): Promise<void>;
|
|
32
32
|
openDevice(): Promise<void>;
|
|
33
33
|
close(app: string): Promise<void>;
|
|
34
34
|
tap(x: number, y: number): Promise<void>;
|
|
@@ -45,7 +45,7 @@ export function getInteractor(device: DeviceInfo): Interactor {
|
|
|
45
45
|
switch (device.platform) {
|
|
46
46
|
case 'android':
|
|
47
47
|
return {
|
|
48
|
-
open: (app) => openAndroidApp(device, app),
|
|
48
|
+
open: (app, options) => openAndroidApp(device, app, options?.activity),
|
|
49
49
|
openDevice: () => openAndroidDevice(device),
|
|
50
50
|
close: (app) => closeAndroidApp(device, app),
|
|
51
51
|
tap: (x, y) => pressAndroid(device, x, y),
|
package/src/utils/output.ts
CHANGED
|
@@ -28,9 +28,10 @@ type SnapshotNode = {
|
|
|
28
28
|
|
|
29
29
|
export function formatSnapshotText(
|
|
30
30
|
data: Record<string, unknown>,
|
|
31
|
-
options: { raw?: boolean } = {},
|
|
31
|
+
options: { raw?: boolean; flatten?: boolean } = {},
|
|
32
32
|
): string {
|
|
33
|
-
const
|
|
33
|
+
const rawNodes = data.nodes;
|
|
34
|
+
const nodes = Array.isArray(rawNodes) ? (rawNodes as SnapshotNode[]) : [];
|
|
34
35
|
const truncated = Boolean(data.truncated);
|
|
35
36
|
const appName = typeof data.appName === 'string' ? data.appName : undefined;
|
|
36
37
|
const appBundleId = typeof data.appBundleId === 'string' ? data.appBundleId : undefined;
|
|
@@ -39,13 +40,17 @@ export function formatSnapshotText(
|
|
|
39
40
|
if (appBundleId) meta.push(`App: ${appBundleId}`);
|
|
40
41
|
const header = `Snapshot: ${nodes.length} nodes${truncated ? ' (truncated)' : ''}`;
|
|
41
42
|
const prefix = meta.length > 0 ? `${meta.join('\n')}\n` : '';
|
|
42
|
-
if (
|
|
43
|
+
if (nodes.length === 0) {
|
|
43
44
|
return `${prefix}${header}\n`;
|
|
44
45
|
}
|
|
45
46
|
if (options.raw) {
|
|
46
47
|
const rawLines = nodes.map((node) => JSON.stringify(node));
|
|
47
48
|
return `${prefix}${header}\n${rawLines.join('\n')}\n`;
|
|
48
49
|
}
|
|
50
|
+
if (options.flatten) {
|
|
51
|
+
const flatLines = nodes.map((node) => formatSnapshotLine(node, 0, false));
|
|
52
|
+
return `${prefix}${header}\n${flatLines.join('\n')}\n`;
|
|
53
|
+
}
|
|
49
54
|
const hiddenGroupDepths: number[] = [];
|
|
50
55
|
const lines: string[] = [];
|
|
51
56
|
for (const node of nodes) {
|
|
@@ -62,28 +67,69 @@ export function formatSnapshotText(
|
|
|
62
67
|
const adjustedDepth = isHiddenGroup
|
|
63
68
|
? depth
|
|
64
69
|
: Math.max(0, depth - hiddenGroupDepths.length);
|
|
65
|
-
|
|
66
|
-
const ref = node.ref ? `@${node.ref}` : '';
|
|
67
|
-
const flags = [
|
|
68
|
-
node.enabled === false ? 'disabled' : null,
|
|
69
|
-
]
|
|
70
|
-
.filter(Boolean)
|
|
71
|
-
.join(', ');
|
|
72
|
-
const flagText = flags ? ` [${flags}]` : '';
|
|
73
|
-
const textPart = label ? ` "${label}"` : '';
|
|
74
|
-
if (isHiddenGroup) {
|
|
75
|
-
lines.push(`${indent}${ref} [${type}]${flagText}`.trimEnd());
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
lines.push(`${indent}${ref} [${type}]${textPart}${flagText}`.trimEnd());
|
|
70
|
+
lines.push(formatSnapshotLine(node, adjustedDepth, isHiddenGroup));
|
|
79
71
|
}
|
|
80
72
|
return `${prefix}${header}\n${lines.join('\n')}\n`;
|
|
81
73
|
}
|
|
82
74
|
|
|
75
|
+
function formatSnapshotLine(node: SnapshotNode, depth: number, hiddenGroup: boolean): string {
|
|
76
|
+
const type = formatRole(node.type ?? 'Element');
|
|
77
|
+
const label = displayLabel(node, type);
|
|
78
|
+
const indent = ' '.repeat(depth);
|
|
79
|
+
const ref = node.ref ? `@${node.ref}` : '';
|
|
80
|
+
const flags = [node.enabled === false ? 'disabled' : null].filter(Boolean).join(', ');
|
|
81
|
+
const flagText = flags ? ` [${flags}]` : '';
|
|
82
|
+
const textPart = label ? ` "${label}"` : '';
|
|
83
|
+
if (hiddenGroup) {
|
|
84
|
+
return `${indent}${ref} [${type}]${flagText}`.trimEnd();
|
|
85
|
+
}
|
|
86
|
+
return `${indent}${ref} [${type}]${textPart}${flagText}`.trimEnd();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function displayLabel(node: SnapshotNode, type: string): string {
|
|
90
|
+
const label = node.label?.trim();
|
|
91
|
+
const value = node.value?.trim();
|
|
92
|
+
if (isEditableRole(type)) {
|
|
93
|
+
// For inputs, prefer current value over placeholder/accessibility label.
|
|
94
|
+
if (value) return value;
|
|
95
|
+
if (label) return label;
|
|
96
|
+
} else if (label) {
|
|
97
|
+
return label;
|
|
98
|
+
}
|
|
99
|
+
if (value) return value;
|
|
100
|
+
const identifier = node.identifier?.trim();
|
|
101
|
+
if (!identifier) return '';
|
|
102
|
+
if (isGenericResourceId(identifier) && (type === 'group' || type === 'image' || type === 'list' || type === 'collection')) {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
return identifier;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isEditableRole(type: string): boolean {
|
|
109
|
+
return type === 'text-field' || type === 'text-view' || type === 'search';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isGenericResourceId(value: string): boolean {
|
|
113
|
+
return /^[\w.]+:id\/[\w.-]+$/i.test(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
83
116
|
function formatRole(type: string): string {
|
|
117
|
+
const raw = type;
|
|
84
118
|
let normalized = type.replace(/XCUIElementType/gi, '').toLowerCase();
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
const isAndroidClass =
|
|
120
|
+
raw.includes('.') &&
|
|
121
|
+
(raw.startsWith('android.') || raw.startsWith('androidx.') || raw.startsWith('com.'));
|
|
122
|
+
if (normalized.startsWith('ax')) {
|
|
123
|
+
normalized = normalized.replace(/^ax/, '');
|
|
124
|
+
}
|
|
125
|
+
if (normalized.includes('.')) {
|
|
126
|
+
normalized = normalized
|
|
127
|
+
.replace(/^android\.widget\./, '')
|
|
128
|
+
.replace(/^android\.view\./, '')
|
|
129
|
+
.replace(/^android\.webkit\./, '')
|
|
130
|
+
.replace(/^androidx\./, '')
|
|
131
|
+
.replace(/^com\.google\.android\./, '')
|
|
132
|
+
.replace(/^com\.android\./, '');
|
|
87
133
|
}
|
|
88
134
|
switch (normalized) {
|
|
89
135
|
case 'application':
|
|
@@ -93,24 +139,40 @@ function formatRole(type: string): string {
|
|
|
93
139
|
case 'tabbar':
|
|
94
140
|
return 'tab-bar';
|
|
95
141
|
case 'button':
|
|
142
|
+
case 'imagebutton':
|
|
96
143
|
return 'button';
|
|
97
144
|
case 'link':
|
|
98
145
|
return 'link';
|
|
99
146
|
case 'cell':
|
|
100
147
|
return 'cell';
|
|
101
148
|
case 'statictext':
|
|
149
|
+
case 'checkedtextview':
|
|
102
150
|
return 'text';
|
|
103
151
|
case 'textfield':
|
|
152
|
+
case 'edittext':
|
|
104
153
|
return 'text-field';
|
|
105
154
|
case 'textview':
|
|
155
|
+
return isAndroidClass ? 'text' : 'text-view';
|
|
156
|
+
case 'textarea':
|
|
106
157
|
return 'text-view';
|
|
107
158
|
case 'switch':
|
|
108
159
|
return 'switch';
|
|
109
160
|
case 'slider':
|
|
110
161
|
return 'slider';
|
|
111
162
|
case 'image':
|
|
163
|
+
case 'imageview':
|
|
112
164
|
return 'image';
|
|
113
|
-
case '
|
|
165
|
+
case 'webview':
|
|
166
|
+
return 'webview';
|
|
167
|
+
case 'framelayout':
|
|
168
|
+
case 'linearlayout':
|
|
169
|
+
case 'relativelayout':
|
|
170
|
+
case 'constraintlayout':
|
|
171
|
+
case 'viewgroup':
|
|
172
|
+
case 'view':
|
|
173
|
+
return 'group';
|
|
174
|
+
case 'listview':
|
|
175
|
+
case 'recyclerview':
|
|
114
176
|
return 'list';
|
|
115
177
|
case 'collectionview':
|
|
116
178
|
return 'collection';
|
|
@@ -122,12 +184,6 @@ function formatRole(type: string): string {
|
|
|
122
184
|
return 'group';
|
|
123
185
|
case 'window':
|
|
124
186
|
return 'window';
|
|
125
|
-
case 'statictext':
|
|
126
|
-
return 'text';
|
|
127
|
-
case 'textfield':
|
|
128
|
-
return 'text-field';
|
|
129
|
-
case 'textarea':
|
|
130
|
-
return 'text-view';
|
|
131
187
|
case 'checkbox':
|
|
132
188
|
return 'checkbox';
|
|
133
189
|
case 'radio':
|
|
@@ -137,6 +193,8 @@ function formatRole(type: string): string {
|
|
|
137
193
|
case 'toolbar':
|
|
138
194
|
return 'toolbar';
|
|
139
195
|
case 'scrollarea':
|
|
196
|
+
case 'scrollview':
|
|
197
|
+
case 'nestedscrollview':
|
|
140
198
|
return 'scroll-area';
|
|
141
199
|
case 'table':
|
|
142
200
|
return 'table';
|