react-native-ai-debugger 1.0.6 → 1.0.8
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 +84 -83
- package/build/core/android.d.ts +138 -0
- package/build/core/android.d.ts.map +1 -1
- package/build/core/android.js +630 -0
- package/build/core/android.js.map +1 -1
- package/build/core/executor.d.ts.map +1 -1
- package/build/core/executor.js +69 -29
- package/build/core/executor.js.map +1 -1
- package/build/core/index.d.ts +4 -3
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js +8 -2
- package/build/core/index.js.map +1 -1
- package/build/core/ios.d.ts +69 -0
- package/build/core/ios.d.ts.map +1 -1
- package/build/core/ios.js +213 -0
- package/build/core/ios.js.map +1 -1
- package/build/index.js +386 -3
- package/build/index.js.map +1 -1
- package/package.json +2 -1
package/build/core/android.js
CHANGED
|
@@ -5,6 +5,8 @@ import path from "path";
|
|
|
5
5
|
import os from "os";
|
|
6
6
|
import sharp from "sharp";
|
|
7
7
|
const execAsync = promisify(exec);
|
|
8
|
+
// XML parsing for uiautomator dump
|
|
9
|
+
import { XMLParser } from "fast-xml-parser";
|
|
8
10
|
// ADB command timeout in milliseconds
|
|
9
11
|
const ADB_TIMEOUT = 30000;
|
|
10
12
|
/**
|
|
@@ -639,6 +641,245 @@ export async function androidKeyEvent(keyCode, deviceId) {
|
|
|
639
641
|
};
|
|
640
642
|
}
|
|
641
643
|
}
|
|
644
|
+
/**
|
|
645
|
+
* Parse bounds string like "[0,0][1080,1920]" to AndroidUIElement bounds
|
|
646
|
+
*/
|
|
647
|
+
function parseBoundsForUIElement(boundsStr) {
|
|
648
|
+
const match = boundsStr.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
649
|
+
if (!match)
|
|
650
|
+
return null;
|
|
651
|
+
const left = parseInt(match[1], 10);
|
|
652
|
+
const top = parseInt(match[2], 10);
|
|
653
|
+
const right = parseInt(match[3], 10);
|
|
654
|
+
const bottom = parseInt(match[4], 10);
|
|
655
|
+
return {
|
|
656
|
+
left,
|
|
657
|
+
top,
|
|
658
|
+
right,
|
|
659
|
+
bottom,
|
|
660
|
+
width: right - left,
|
|
661
|
+
height: bottom - top
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Parse uiautomator XML dump into element array
|
|
666
|
+
*/
|
|
667
|
+
function parseUIAutomatorXML(xml) {
|
|
668
|
+
const elements = [];
|
|
669
|
+
// Match all node elements with their attributes
|
|
670
|
+
const nodeRegex = /<node\s+([^>]+)\/?>|<node\s+([^>]+)>/g;
|
|
671
|
+
let match;
|
|
672
|
+
while ((match = nodeRegex.exec(xml)) !== null) {
|
|
673
|
+
const attrStr = match[1] || match[2];
|
|
674
|
+
if (!attrStr)
|
|
675
|
+
continue;
|
|
676
|
+
// Extract attributes
|
|
677
|
+
const getAttr = (name) => {
|
|
678
|
+
const attrMatch = attrStr.match(new RegExp(`${name}="([^"]*)"`));
|
|
679
|
+
return attrMatch ? attrMatch[1] : "";
|
|
680
|
+
};
|
|
681
|
+
const boundsStr = getAttr("bounds");
|
|
682
|
+
const bounds = parseBoundsForUIElement(boundsStr);
|
|
683
|
+
if (!bounds)
|
|
684
|
+
continue;
|
|
685
|
+
const element = {
|
|
686
|
+
text: getAttr("text"),
|
|
687
|
+
contentDesc: getAttr("content-desc"),
|
|
688
|
+
resourceId: getAttr("resource-id"),
|
|
689
|
+
className: getAttr("class"),
|
|
690
|
+
bounds,
|
|
691
|
+
center: {
|
|
692
|
+
x: Math.round((bounds.left + bounds.right) / 2),
|
|
693
|
+
y: Math.round((bounds.top + bounds.bottom) / 2)
|
|
694
|
+
},
|
|
695
|
+
clickable: getAttr("clickable") === "true",
|
|
696
|
+
enabled: getAttr("enabled") === "true",
|
|
697
|
+
focused: getAttr("focused") === "true",
|
|
698
|
+
scrollable: getAttr("scrollable") === "true",
|
|
699
|
+
selected: getAttr("selected") === "true"
|
|
700
|
+
};
|
|
701
|
+
elements.push(element);
|
|
702
|
+
}
|
|
703
|
+
return elements;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Match element against find options
|
|
707
|
+
*/
|
|
708
|
+
function matchesElement(element, options) {
|
|
709
|
+
if (options.text !== undefined) {
|
|
710
|
+
if (element.text !== options.text)
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
if (options.textContains !== undefined) {
|
|
714
|
+
if (!element.text.toLowerCase().includes(options.textContains.toLowerCase()))
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
if (options.contentDesc !== undefined) {
|
|
718
|
+
if (element.contentDesc !== options.contentDesc)
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
if (options.contentDescContains !== undefined) {
|
|
722
|
+
if (!element.contentDesc.toLowerCase().includes(options.contentDescContains.toLowerCase()))
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
if (options.resourceId !== undefined) {
|
|
726
|
+
// Support both full "com.app:id/button" and short "button" forms
|
|
727
|
+
const shortId = element.resourceId.split("/").pop() || "";
|
|
728
|
+
if (element.resourceId !== options.resourceId && shortId !== options.resourceId)
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Get UI accessibility tree from Android device using uiautomator
|
|
735
|
+
*/
|
|
736
|
+
export async function androidGetUITree(deviceId) {
|
|
737
|
+
try {
|
|
738
|
+
const adbAvailable = await isAdbAvailable();
|
|
739
|
+
if (!adbAvailable) {
|
|
740
|
+
return {
|
|
741
|
+
success: false,
|
|
742
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
746
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
747
|
+
if (!device) {
|
|
748
|
+
return {
|
|
749
|
+
success: false,
|
|
750
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
// Dump UI hierarchy to device
|
|
754
|
+
const remotePath = "/sdcard/ui_dump.xml";
|
|
755
|
+
await execAsync(`adb ${deviceArg} shell uiautomator dump ${remotePath}`, {
|
|
756
|
+
timeout: ADB_TIMEOUT
|
|
757
|
+
});
|
|
758
|
+
// Read the XML content
|
|
759
|
+
const { stdout } = await execAsync(`adb ${deviceArg} shell cat ${remotePath}`, {
|
|
760
|
+
timeout: ADB_TIMEOUT
|
|
761
|
+
});
|
|
762
|
+
// Clean up remote file
|
|
763
|
+
await execAsync(`adb ${deviceArg} shell rm ${remotePath}`, {
|
|
764
|
+
timeout: ADB_TIMEOUT
|
|
765
|
+
}).catch(() => { });
|
|
766
|
+
const elements = parseUIAutomatorXML(stdout);
|
|
767
|
+
return {
|
|
768
|
+
success: true,
|
|
769
|
+
elements,
|
|
770
|
+
rawXml: stdout
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
return {
|
|
775
|
+
success: false,
|
|
776
|
+
error: `Failed to get UI tree: ${error instanceof Error ? error.message : String(error)}`
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Find element(s) in the UI tree matching the given criteria
|
|
782
|
+
*/
|
|
783
|
+
export async function androidFindElement(options, deviceId) {
|
|
784
|
+
try {
|
|
785
|
+
// Validate that at least one search criteria is provided
|
|
786
|
+
if (!options.text && !options.textContains && !options.contentDesc &&
|
|
787
|
+
!options.contentDescContains && !options.resourceId) {
|
|
788
|
+
return {
|
|
789
|
+
success: false,
|
|
790
|
+
found: false,
|
|
791
|
+
error: "At least one search criteria (text, textContains, contentDesc, contentDescContains, or resourceId) must be provided"
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const treeResult = await androidGetUITree(deviceId);
|
|
795
|
+
if (!treeResult.success || !treeResult.elements) {
|
|
796
|
+
return {
|
|
797
|
+
success: false,
|
|
798
|
+
found: false,
|
|
799
|
+
error: treeResult.error
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
// Find matching elements
|
|
803
|
+
const matches = treeResult.elements.filter(el => matchesElement(el, options));
|
|
804
|
+
if (matches.length === 0) {
|
|
805
|
+
return {
|
|
806
|
+
success: true,
|
|
807
|
+
found: false,
|
|
808
|
+
matchCount: 0
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
// Select the element at the specified index (default 0)
|
|
812
|
+
const index = options.index ?? 0;
|
|
813
|
+
const selectedElement = matches[index];
|
|
814
|
+
if (!selectedElement) {
|
|
815
|
+
return {
|
|
816
|
+
success: true,
|
|
817
|
+
found: false,
|
|
818
|
+
matchCount: matches.length,
|
|
819
|
+
error: `Index ${index} out of bounds. Found ${matches.length} matching element(s).`
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
success: true,
|
|
824
|
+
found: true,
|
|
825
|
+
element: selectedElement,
|
|
826
|
+
allMatches: matches,
|
|
827
|
+
matchCount: matches.length
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
return {
|
|
832
|
+
success: false,
|
|
833
|
+
found: false,
|
|
834
|
+
error: `Failed to find element: ${error instanceof Error ? error.message : String(error)}`
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Wait for element to appear on screen with polling
|
|
840
|
+
*/
|
|
841
|
+
export async function androidWaitForElement(options, deviceId) {
|
|
842
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
843
|
+
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
844
|
+
const startTime = Date.now();
|
|
845
|
+
// Validate that at least one search criteria is provided
|
|
846
|
+
if (!options.text && !options.textContains && !options.contentDesc &&
|
|
847
|
+
!options.contentDescContains && !options.resourceId) {
|
|
848
|
+
return {
|
|
849
|
+
success: false,
|
|
850
|
+
found: false,
|
|
851
|
+
timedOut: false,
|
|
852
|
+
error: "At least one search criteria (text, textContains, contentDesc, contentDescContains, or resourceId) must be provided"
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
856
|
+
const result = await androidFindElement(options, deviceId);
|
|
857
|
+
if (result.found && result.element) {
|
|
858
|
+
return {
|
|
859
|
+
...result,
|
|
860
|
+
elapsedMs: Date.now() - startTime,
|
|
861
|
+
timedOut: false
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
// If there was an error (not just "not found"), return it
|
|
865
|
+
if (!result.success) {
|
|
866
|
+
return {
|
|
867
|
+
...result,
|
|
868
|
+
elapsedMs: Date.now() - startTime,
|
|
869
|
+
timedOut: false
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
// Wait before next poll
|
|
873
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
success: true,
|
|
877
|
+
found: false,
|
|
878
|
+
elapsedMs: Date.now() - startTime,
|
|
879
|
+
timedOut: true,
|
|
880
|
+
error: `Timed out after ${timeoutMs}ms waiting for element`
|
|
881
|
+
};
|
|
882
|
+
}
|
|
642
883
|
/**
|
|
643
884
|
* Get device screen size
|
|
644
885
|
*/
|
|
@@ -683,4 +924,393 @@ export async function androidGetScreenSize(deviceId) {
|
|
|
683
924
|
};
|
|
684
925
|
}
|
|
685
926
|
}
|
|
927
|
+
/**
|
|
928
|
+
* Simplify Android class name for display
|
|
929
|
+
* android.widget.Button -> Button
|
|
930
|
+
* android.widget.TextView -> TextView
|
|
931
|
+
*/
|
|
932
|
+
function simplifyClassName(className) {
|
|
933
|
+
if (!className)
|
|
934
|
+
return "Unknown";
|
|
935
|
+
const parts = className.split(".");
|
|
936
|
+
return parts[parts.length - 1];
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Parse bounds string "[left,top][right,bottom]" to object
|
|
940
|
+
*/
|
|
941
|
+
function parseBounds(boundsStr) {
|
|
942
|
+
const match = boundsStr?.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
943
|
+
if (!match)
|
|
944
|
+
return null;
|
|
945
|
+
return {
|
|
946
|
+
left: parseInt(match[1], 10),
|
|
947
|
+
top: parseInt(match[2], 10),
|
|
948
|
+
right: parseInt(match[3], 10),
|
|
949
|
+
bottom: parseInt(match[4], 10)
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Parse a single node from uiautomator XML
|
|
954
|
+
*/
|
|
955
|
+
function parseUiNode(node) {
|
|
956
|
+
const attrs = node["@_bounds"]
|
|
957
|
+
? node
|
|
958
|
+
: node.node
|
|
959
|
+
? (Array.isArray(node.node) ? node.node[0] : node.node)
|
|
960
|
+
: null;
|
|
961
|
+
if (!attrs)
|
|
962
|
+
return null;
|
|
963
|
+
const boundsStr = attrs["@_bounds"];
|
|
964
|
+
const bounds = parseBounds(boundsStr);
|
|
965
|
+
if (!bounds)
|
|
966
|
+
return null;
|
|
967
|
+
const width = bounds.right - bounds.left;
|
|
968
|
+
const height = bounds.bottom - bounds.top;
|
|
969
|
+
const centerX = Math.round(bounds.left + width / 2);
|
|
970
|
+
const centerY = Math.round(bounds.top + height / 2);
|
|
971
|
+
const element = {
|
|
972
|
+
class: simplifyClassName(attrs["@_class"] || ""),
|
|
973
|
+
bounds,
|
|
974
|
+
frame: {
|
|
975
|
+
x: bounds.left,
|
|
976
|
+
y: bounds.top,
|
|
977
|
+
width,
|
|
978
|
+
height
|
|
979
|
+
},
|
|
980
|
+
tap: {
|
|
981
|
+
x: centerX,
|
|
982
|
+
y: centerY
|
|
983
|
+
},
|
|
984
|
+
children: []
|
|
985
|
+
};
|
|
986
|
+
// Add optional attributes
|
|
987
|
+
if (attrs["@_text"])
|
|
988
|
+
element.text = attrs["@_text"];
|
|
989
|
+
if (attrs["@_content-desc"])
|
|
990
|
+
element.contentDesc = attrs["@_content-desc"];
|
|
991
|
+
if (attrs["@_resource-id"])
|
|
992
|
+
element.resourceId = attrs["@_resource-id"];
|
|
993
|
+
if (attrs["@_checkable"] === "true")
|
|
994
|
+
element.checkable = true;
|
|
995
|
+
if (attrs["@_checked"] === "true")
|
|
996
|
+
element.checked = true;
|
|
997
|
+
if (attrs["@_clickable"] === "true")
|
|
998
|
+
element.clickable = true;
|
|
999
|
+
if (attrs["@_enabled"] === "true")
|
|
1000
|
+
element.enabled = true;
|
|
1001
|
+
if (attrs["@_focusable"] === "true")
|
|
1002
|
+
element.focusable = true;
|
|
1003
|
+
if (attrs["@_focused"] === "true")
|
|
1004
|
+
element.focused = true;
|
|
1005
|
+
if (attrs["@_scrollable"] === "true")
|
|
1006
|
+
element.scrollable = true;
|
|
1007
|
+
if (attrs["@_selected"] === "true")
|
|
1008
|
+
element.selected = true;
|
|
1009
|
+
return element;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Recursively parse UI hierarchy from XML node
|
|
1013
|
+
*/
|
|
1014
|
+
function parseHierarchy(node) {
|
|
1015
|
+
const results = [];
|
|
1016
|
+
// Handle the node itself
|
|
1017
|
+
if (node["@_bounds"]) {
|
|
1018
|
+
const element = parseUiNode(node);
|
|
1019
|
+
if (element) {
|
|
1020
|
+
// Parse children
|
|
1021
|
+
if (node.node) {
|
|
1022
|
+
const children = Array.isArray(node.node) ? node.node : [node.node];
|
|
1023
|
+
for (const child of children) {
|
|
1024
|
+
element.children.push(...parseHierarchy(child));
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
results.push(element);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
else if (node.node) {
|
|
1031
|
+
// This is a container without bounds (like hierarchy root)
|
|
1032
|
+
const children = Array.isArray(node.node) ? node.node : [node.node];
|
|
1033
|
+
for (const child of children) {
|
|
1034
|
+
results.push(...parseHierarchy(child));
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return results;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Format accessibility tree for display (similar to iOS format)
|
|
1041
|
+
*/
|
|
1042
|
+
function formatAndroidAccessibilityTree(elements, indent = 0) {
|
|
1043
|
+
const lines = [];
|
|
1044
|
+
const prefix = " ".repeat(indent);
|
|
1045
|
+
for (const element of elements) {
|
|
1046
|
+
const parts = [];
|
|
1047
|
+
// [ClassName] "text" or "content-desc"
|
|
1048
|
+
parts.push(`[${element.class}]`);
|
|
1049
|
+
// Add label (text or content-desc)
|
|
1050
|
+
const label = element.text || element.contentDesc;
|
|
1051
|
+
if (label) {
|
|
1052
|
+
parts.push(`"${label}"`);
|
|
1053
|
+
}
|
|
1054
|
+
// Add frame and tap coordinates
|
|
1055
|
+
const f = element.frame;
|
|
1056
|
+
parts.push(`frame=(${f.x}, ${f.y}, ${f.width}x${f.height}) tap=(${element.tap.x}, ${element.tap.y})`);
|
|
1057
|
+
lines.push(`${prefix}${parts.join(" ")}`);
|
|
1058
|
+
// Recurse into children
|
|
1059
|
+
if (element.children.length > 0) {
|
|
1060
|
+
lines.push(formatAndroidAccessibilityTree(element.children, indent + 1));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return lines.join("\n");
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Flatten element tree to array for searching
|
|
1067
|
+
*/
|
|
1068
|
+
function flattenElements(elements) {
|
|
1069
|
+
const result = [];
|
|
1070
|
+
for (const element of elements) {
|
|
1071
|
+
result.push(element);
|
|
1072
|
+
if (element.children.length > 0) {
|
|
1073
|
+
result.push(...flattenElements(element.children));
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return result;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Get the UI hierarchy from the connected Android device using uiautomator dump
|
|
1080
|
+
*/
|
|
1081
|
+
export async function androidDescribeAll(deviceId) {
|
|
1082
|
+
try {
|
|
1083
|
+
const adbAvailable = await isAdbAvailable();
|
|
1084
|
+
if (!adbAvailable) {
|
|
1085
|
+
return {
|
|
1086
|
+
success: false,
|
|
1087
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
1091
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
1092
|
+
if (!device) {
|
|
1093
|
+
return {
|
|
1094
|
+
success: false,
|
|
1095
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
// Use file-based approach (most reliable across devices)
|
|
1099
|
+
// /dev/tty doesn't work on most emulators/devices
|
|
1100
|
+
const remotePath = "/sdcard/ui_dump.xml";
|
|
1101
|
+
await execAsync(`adb ${deviceArg} shell uiautomator dump ${remotePath}`, {
|
|
1102
|
+
timeout: ADB_TIMEOUT
|
|
1103
|
+
});
|
|
1104
|
+
const { stdout } = await execAsync(`adb ${deviceArg} shell cat ${remotePath}`, {
|
|
1105
|
+
timeout: ADB_TIMEOUT,
|
|
1106
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1107
|
+
});
|
|
1108
|
+
const xmlContent = stdout.trim();
|
|
1109
|
+
// Clean up
|
|
1110
|
+
await execAsync(`adb ${deviceArg} shell rm ${remotePath}`, {
|
|
1111
|
+
timeout: 5000
|
|
1112
|
+
}).catch(() => { });
|
|
1113
|
+
if (!xmlContent || !xmlContent.includes("<hierarchy")) {
|
|
1114
|
+
return {
|
|
1115
|
+
success: false,
|
|
1116
|
+
error: "Failed to get UI hierarchy. Make sure the device screen is unlocked and the app is in foreground."
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
// Parse XML
|
|
1120
|
+
const parser = new XMLParser({
|
|
1121
|
+
ignoreAttributes: false,
|
|
1122
|
+
attributeNamePrefix: "@_"
|
|
1123
|
+
});
|
|
1124
|
+
const parsed = parser.parse(xmlContent);
|
|
1125
|
+
if (!parsed.hierarchy) {
|
|
1126
|
+
return {
|
|
1127
|
+
success: false,
|
|
1128
|
+
error: "Invalid UI hierarchy XML structure"
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
const elements = parseHierarchy(parsed.hierarchy);
|
|
1132
|
+
const formatted = formatAndroidAccessibilityTree(elements);
|
|
1133
|
+
return {
|
|
1134
|
+
success: true,
|
|
1135
|
+
elements,
|
|
1136
|
+
formatted
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
catch (error) {
|
|
1140
|
+
return {
|
|
1141
|
+
success: false,
|
|
1142
|
+
error: `Failed to get UI hierarchy: ${error instanceof Error ? error.message : String(error)}`
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Get accessibility info for the UI element at specific coordinates
|
|
1148
|
+
*/
|
|
1149
|
+
export async function androidDescribePoint(x, y, deviceId) {
|
|
1150
|
+
try {
|
|
1151
|
+
// First get the full hierarchy
|
|
1152
|
+
const result = await androidDescribeAll(deviceId);
|
|
1153
|
+
if (!result.success || !result.elements) {
|
|
1154
|
+
return result;
|
|
1155
|
+
}
|
|
1156
|
+
// Flatten and find elements containing the point
|
|
1157
|
+
const allElements = flattenElements(result.elements);
|
|
1158
|
+
// Find all elements whose bounds contain the point
|
|
1159
|
+
const matchingElements = allElements.filter((el) => {
|
|
1160
|
+
const b = el.bounds;
|
|
1161
|
+
return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom;
|
|
1162
|
+
});
|
|
1163
|
+
if (matchingElements.length === 0) {
|
|
1164
|
+
return {
|
|
1165
|
+
success: true,
|
|
1166
|
+
formatted: `No element found at (${x}, ${y})`
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
// Return the deepest (smallest) element that contains the point
|
|
1170
|
+
// Sort by area (smallest first) to get the most specific element
|
|
1171
|
+
matchingElements.sort((a, b) => {
|
|
1172
|
+
const areaA = a.frame.width * a.frame.height;
|
|
1173
|
+
const areaB = b.frame.width * b.frame.height;
|
|
1174
|
+
return areaA - areaB;
|
|
1175
|
+
});
|
|
1176
|
+
const element = matchingElements[0];
|
|
1177
|
+
// Format detailed output
|
|
1178
|
+
const lines = [];
|
|
1179
|
+
const label = element.text || element.contentDesc;
|
|
1180
|
+
lines.push(`[${element.class}]${label ? ` "${label}"` : ""} frame=(${element.frame.x}, ${element.frame.y}, ${element.frame.width}x${element.frame.height}) tap=(${element.tap.x}, ${element.tap.y})`);
|
|
1181
|
+
if (element.resourceId) {
|
|
1182
|
+
lines.push(` resource-id: ${element.resourceId}`);
|
|
1183
|
+
}
|
|
1184
|
+
if (element.contentDesc && element.text) {
|
|
1185
|
+
// Show content-desc separately if we showed text as label
|
|
1186
|
+
lines.push(` content-desc: ${element.contentDesc}`);
|
|
1187
|
+
}
|
|
1188
|
+
if (element.text && element.contentDesc) {
|
|
1189
|
+
// Show text separately if we showed content-desc as label
|
|
1190
|
+
lines.push(` text: ${element.text}`);
|
|
1191
|
+
}
|
|
1192
|
+
// Show state flags
|
|
1193
|
+
const flags = [];
|
|
1194
|
+
if (element.clickable)
|
|
1195
|
+
flags.push("clickable");
|
|
1196
|
+
if (element.enabled)
|
|
1197
|
+
flags.push("enabled");
|
|
1198
|
+
if (element.focusable)
|
|
1199
|
+
flags.push("focusable");
|
|
1200
|
+
if (element.focused)
|
|
1201
|
+
flags.push("focused");
|
|
1202
|
+
if (element.scrollable)
|
|
1203
|
+
flags.push("scrollable");
|
|
1204
|
+
if (element.selected)
|
|
1205
|
+
flags.push("selected");
|
|
1206
|
+
if (element.checked)
|
|
1207
|
+
flags.push("checked");
|
|
1208
|
+
if (flags.length > 0) {
|
|
1209
|
+
lines.push(` state: ${flags.join(", ")}`);
|
|
1210
|
+
}
|
|
1211
|
+
return {
|
|
1212
|
+
success: true,
|
|
1213
|
+
elements: [element],
|
|
1214
|
+
formatted: lines.join("\n")
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
return {
|
|
1219
|
+
success: false,
|
|
1220
|
+
error: `Failed to describe point: ${error instanceof Error ? error.message : String(error)}`
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Tap an element by its text, content-description, or resource-id
|
|
1226
|
+
*/
|
|
1227
|
+
export async function androidTapElement(options) {
|
|
1228
|
+
try {
|
|
1229
|
+
const { text, textContains, contentDesc, contentDescContains, resourceId, index = 0, deviceId } = options;
|
|
1230
|
+
// Validate that at least one search criterion is provided
|
|
1231
|
+
if (!text && !textContains && !contentDesc && !contentDescContains && !resourceId) {
|
|
1232
|
+
return {
|
|
1233
|
+
success: false,
|
|
1234
|
+
error: "At least one of text, textContains, contentDesc, contentDescContains, or resourceId must be provided"
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
// Get the UI hierarchy
|
|
1238
|
+
const result = await androidDescribeAll(deviceId);
|
|
1239
|
+
if (!result.success || !result.elements) {
|
|
1240
|
+
return {
|
|
1241
|
+
success: false,
|
|
1242
|
+
error: result.error || "Failed to get UI hierarchy"
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
// Flatten and search
|
|
1246
|
+
const allElements = flattenElements(result.elements);
|
|
1247
|
+
// Filter elements based on search criteria
|
|
1248
|
+
const matchingElements = allElements.filter((el) => {
|
|
1249
|
+
if (text && el.text !== text)
|
|
1250
|
+
return false;
|
|
1251
|
+
if (textContains && (!el.text || !el.text.toLowerCase().includes(textContains.toLowerCase())))
|
|
1252
|
+
return false;
|
|
1253
|
+
if (contentDesc && el.contentDesc !== contentDesc)
|
|
1254
|
+
return false;
|
|
1255
|
+
if (contentDescContains && (!el.contentDesc || !el.contentDesc.toLowerCase().includes(contentDescContains.toLowerCase())))
|
|
1256
|
+
return false;
|
|
1257
|
+
if (resourceId) {
|
|
1258
|
+
// Support both full resource-id and short form
|
|
1259
|
+
if (!el.resourceId)
|
|
1260
|
+
return false;
|
|
1261
|
+
if (el.resourceId !== resourceId && !el.resourceId.endsWith(`:id/${resourceId}`))
|
|
1262
|
+
return false;
|
|
1263
|
+
}
|
|
1264
|
+
return true;
|
|
1265
|
+
});
|
|
1266
|
+
if (matchingElements.length === 0) {
|
|
1267
|
+
const criteria = [];
|
|
1268
|
+
if (text)
|
|
1269
|
+
criteria.push(`text="${text}"`);
|
|
1270
|
+
if (textContains)
|
|
1271
|
+
criteria.push(`textContains="${textContains}"`);
|
|
1272
|
+
if (contentDesc)
|
|
1273
|
+
criteria.push(`contentDesc="${contentDesc}"`);
|
|
1274
|
+
if (contentDescContains)
|
|
1275
|
+
criteria.push(`contentDescContains="${contentDescContains}"`);
|
|
1276
|
+
if (resourceId)
|
|
1277
|
+
criteria.push(`resourceId="${resourceId}"`);
|
|
1278
|
+
return {
|
|
1279
|
+
success: false,
|
|
1280
|
+
error: `Element not found: ${criteria.join(", ")}`
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
if (index >= matchingElements.length) {
|
|
1284
|
+
return {
|
|
1285
|
+
success: false,
|
|
1286
|
+
error: `Index ${index} out of range. Found ${matchingElements.length} matching element(s).`
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
const element = matchingElements[index];
|
|
1290
|
+
const label = element.text || element.contentDesc || element.resourceId || element.class;
|
|
1291
|
+
// Log if multiple matches
|
|
1292
|
+
let resultMessage;
|
|
1293
|
+
if (matchingElements.length > 1) {
|
|
1294
|
+
resultMessage = `Found ${matchingElements.length} elements, tapping "${label}" (index ${index}) at (${element.tap.x}, ${element.tap.y})`;
|
|
1295
|
+
}
|
|
1296
|
+
else {
|
|
1297
|
+
resultMessage = `Tapped "${label}" at (${element.tap.x}, ${element.tap.y})`;
|
|
1298
|
+
}
|
|
1299
|
+
// Perform the tap
|
|
1300
|
+
const tapResult = await androidTap(element.tap.x, element.tap.y, deviceId);
|
|
1301
|
+
if (!tapResult.success) {
|
|
1302
|
+
return tapResult;
|
|
1303
|
+
}
|
|
1304
|
+
return {
|
|
1305
|
+
success: true,
|
|
1306
|
+
result: resultMessage
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
catch (error) {
|
|
1310
|
+
return {
|
|
1311
|
+
success: false,
|
|
1312
|
+
error: `Failed to tap element: ${error instanceof Error ? error.message : String(error)}`
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
686
1316
|
//# sourceMappingURL=android.js.map
|