devlens-mcp 0.3.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 (175) hide show
  1. package/.claude/settings.json +12 -0
  2. package/.claude/settings.local.json +17 -0
  3. package/INSTALLATION_GUIDE.md +354 -0
  4. package/QUICK_START.md +153 -0
  5. package/README.md +354 -0
  6. package/bin/cli.ts +22 -0
  7. package/bin/register.ts +96 -0
  8. package/dist/bin/cli.d.ts +3 -0
  9. package/dist/bin/cli.d.ts.map +1 -0
  10. package/dist/bin/cli.js +20 -0
  11. package/dist/bin/cli.js.map +1 -0
  12. package/dist/bin/register.d.ts +10 -0
  13. package/dist/bin/register.d.ts.map +1 -0
  14. package/dist/bin/register.js +92 -0
  15. package/dist/bin/register.js.map +1 -0
  16. package/dist/src/config/devlens-config.d.ts +92 -0
  17. package/dist/src/config/devlens-config.d.ts.map +1 -0
  18. package/dist/src/config/devlens-config.js +70 -0
  19. package/dist/src/config/devlens-config.js.map +1 -0
  20. package/dist/src/index.d.ts +35 -0
  21. package/dist/src/index.d.ts.map +1 -0
  22. package/dist/src/index.js +8 -0
  23. package/dist/src/index.js.map +1 -0
  24. package/dist/src/metro/cdp-client.d.ts +48 -0
  25. package/dist/src/metro/cdp-client.d.ts.map +1 -0
  26. package/dist/src/metro/cdp-client.js +127 -0
  27. package/dist/src/metro/cdp-client.js.map +1 -0
  28. package/dist/src/metro/log-collector.d.ts +30 -0
  29. package/dist/src/metro/log-collector.d.ts.map +1 -0
  30. package/dist/src/metro/log-collector.js +114 -0
  31. package/dist/src/metro/log-collector.js.map +1 -0
  32. package/dist/src/metro/metro-bridge.d.ts +56 -0
  33. package/dist/src/metro/metro-bridge.d.ts.map +1 -0
  34. package/dist/src/metro/metro-bridge.js +255 -0
  35. package/dist/src/metro/metro-bridge.js.map +1 -0
  36. package/dist/src/metro/network-inspector.d.ts +34 -0
  37. package/dist/src/metro/network-inspector.d.ts.map +1 -0
  38. package/dist/src/metro/network-inspector.js +100 -0
  39. package/dist/src/metro/network-inspector.js.map +1 -0
  40. package/dist/src/platform/android/adb.d.ts +50 -0
  41. package/dist/src/platform/android/adb.d.ts.map +1 -0
  42. package/dist/src/platform/android/adb.js +137 -0
  43. package/dist/src/platform/android/adb.js.map +1 -0
  44. package/dist/src/platform/android/android-device.d.ts +21 -0
  45. package/dist/src/platform/android/android-device.d.ts.map +1 -0
  46. package/dist/src/platform/android/android-device.js +94 -0
  47. package/dist/src/platform/android/android-device.js.map +1 -0
  48. package/dist/src/platform/android/ui-automator.d.ts +17 -0
  49. package/dist/src/platform/android/ui-automator.d.ts.map +1 -0
  50. package/dist/src/platform/android/ui-automator.js +126 -0
  51. package/dist/src/platform/android/ui-automator.js.map +1 -0
  52. package/dist/src/platform/device-manager.d.ts +28 -0
  53. package/dist/src/platform/device-manager.d.ts.map +1 -0
  54. package/dist/src/platform/device-manager.js +185 -0
  55. package/dist/src/platform/device-manager.js.map +1 -0
  56. package/dist/src/platform/device.d.ts +86 -0
  57. package/dist/src/platform/device.d.ts.map +1 -0
  58. package/dist/src/platform/device.js +7 -0
  59. package/dist/src/platform/device.js.map +1 -0
  60. package/dist/src/platform/ios/accessibility.d.ts +17 -0
  61. package/dist/src/platform/ios/accessibility.d.ts.map +1 -0
  62. package/dist/src/platform/ios/accessibility.js +159 -0
  63. package/dist/src/platform/ios/accessibility.js.map +1 -0
  64. package/dist/src/platform/ios/ios-device.d.ts +22 -0
  65. package/dist/src/platform/ios/ios-device.d.ts.map +1 -0
  66. package/dist/src/platform/ios/ios-device.js +97 -0
  67. package/dist/src/platform/ios/ios-device.js.map +1 -0
  68. package/dist/src/platform/ios/simctl.d.ts +54 -0
  69. package/dist/src/platform/ios/simctl.d.ts.map +1 -0
  70. package/dist/src/platform/ios/simctl.js +192 -0
  71. package/dist/src/platform/ios/simctl.js.map +1 -0
  72. package/dist/src/server.d.ts +3 -0
  73. package/dist/src/server.d.ts.map +1 -0
  74. package/dist/src/server.js +176 -0
  75. package/dist/src/server.js.map +1 -0
  76. package/dist/src/snapshot/formatter.d.ts +18 -0
  77. package/dist/src/snapshot/formatter.d.ts.map +1 -0
  78. package/dist/src/snapshot/formatter.js +86 -0
  79. package/dist/src/snapshot/formatter.js.map +1 -0
  80. package/dist/src/snapshot/ref-registry.d.ts +67 -0
  81. package/dist/src/snapshot/ref-registry.d.ts.map +1 -0
  82. package/dist/src/snapshot/ref-registry.js +169 -0
  83. package/dist/src/snapshot/ref-registry.js.map +1 -0
  84. package/dist/src/snapshot/snapshot-differ.d.ts +57 -0
  85. package/dist/src/snapshot/snapshot-differ.d.ts.map +1 -0
  86. package/dist/src/snapshot/snapshot-differ.js +153 -0
  87. package/dist/src/snapshot/snapshot-differ.js.map +1 -0
  88. package/dist/src/tools/app-tools.d.ts +71 -0
  89. package/dist/src/tools/app-tools.d.ts.map +1 -0
  90. package/dist/src/tools/app-tools.js +97 -0
  91. package/dist/src/tools/app-tools.js.map +1 -0
  92. package/dist/src/tools/device-tools.d.ts +53 -0
  93. package/dist/src/tools/device-tools.d.ts.map +1 -0
  94. package/dist/src/tools/device-tools.js +86 -0
  95. package/dist/src/tools/device-tools.js.map +1 -0
  96. package/dist/src/tools/ds-tools.d.ts +65 -0
  97. package/dist/src/tools/ds-tools.d.ts.map +1 -0
  98. package/dist/src/tools/ds-tools.js +314 -0
  99. package/dist/src/tools/ds-tools.js.map +1 -0
  100. package/dist/src/tools/interaction-tools.d.ts +248 -0
  101. package/dist/src/tools/interaction-tools.d.ts.map +1 -0
  102. package/dist/src/tools/interaction-tools.js +391 -0
  103. package/dist/src/tools/interaction-tools.js.map +1 -0
  104. package/dist/src/tools/metro-tools.d.ts +115 -0
  105. package/dist/src/tools/metro-tools.d.ts.map +1 -0
  106. package/dist/src/tools/metro-tools.js +270 -0
  107. package/dist/src/tools/metro-tools.js.map +1 -0
  108. package/dist/src/tools/navigation-tools.d.ts +36 -0
  109. package/dist/src/tools/navigation-tools.d.ts.map +1 -0
  110. package/dist/src/tools/navigation-tools.js +60 -0
  111. package/dist/src/tools/navigation-tools.js.map +1 -0
  112. package/dist/src/tools/screenshot-tools.d.ts +298 -0
  113. package/dist/src/tools/screenshot-tools.d.ts.map +1 -0
  114. package/dist/src/tools/screenshot-tools.js +565 -0
  115. package/dist/src/tools/screenshot-tools.js.map +1 -0
  116. package/dist/src/tools/snapshot-tools.d.ts +161 -0
  117. package/dist/src/tools/snapshot-tools.d.ts.map +1 -0
  118. package/dist/src/tools/snapshot-tools.js +479 -0
  119. package/dist/src/tools/snapshot-tools.js.map +1 -0
  120. package/dist/src/utils/image-preprocess.d.ts +49 -0
  121. package/dist/src/utils/image-preprocess.d.ts.map +1 -0
  122. package/dist/src/utils/image-preprocess.js +322 -0
  123. package/dist/src/utils/image-preprocess.js.map +1 -0
  124. package/dist/src/utils/retry.d.ts +21 -0
  125. package/dist/src/utils/retry.d.ts.map +1 -0
  126. package/dist/src/utils/retry.js +33 -0
  127. package/dist/src/utils/retry.js.map +1 -0
  128. package/dist/src/visual/comparator.d.ts +51 -0
  129. package/dist/src/visual/comparator.d.ts.map +1 -0
  130. package/dist/src/visual/comparator.js +119 -0
  131. package/dist/src/visual/comparator.js.map +1 -0
  132. package/dist/src/visual/layout-analyzer.d.ts +64 -0
  133. package/dist/src/visual/layout-analyzer.d.ts.map +1 -0
  134. package/dist/src/visual/layout-analyzer.js +198 -0
  135. package/dist/src/visual/layout-analyzer.js.map +1 -0
  136. package/dist/src/visual/screenshot.d.ts +17 -0
  137. package/dist/src/visual/screenshot.d.ts.map +1 -0
  138. package/dist/src/visual/screenshot.js +39 -0
  139. package/dist/src/visual/screenshot.js.map +1 -0
  140. package/docs/figma-workflow.md +289 -0
  141. package/docs/setup-guide.md +360 -0
  142. package/docs/tool-reference.md +622 -0
  143. package/package.json +57 -0
  144. package/src/config/devlens-config.ts +76 -0
  145. package/src/index.ts +5 -0
  146. package/src/metro/cdp-client.ts +160 -0
  147. package/src/metro/log-collector.ts +137 -0
  148. package/src/metro/metro-bridge.ts +307 -0
  149. package/src/metro/network-inspector.ts +134 -0
  150. package/src/platform/android/adb.ts +200 -0
  151. package/src/platform/android/android-device.ts +116 -0
  152. package/src/platform/android/ui-automator.ts +141 -0
  153. package/src/platform/device-manager.ts +229 -0
  154. package/src/platform/device.ts +110 -0
  155. package/src/platform/ios/accessibility.ts +189 -0
  156. package/src/platform/ios/ios-device.ts +116 -0
  157. package/src/platform/ios/simctl.ts +244 -0
  158. package/src/server.ts +228 -0
  159. package/src/snapshot/formatter.ts +102 -0
  160. package/src/snapshot/ref-registry.ts +230 -0
  161. package/src/snapshot/snapshot-differ.ts +220 -0
  162. package/src/tools/app-tools.ts +111 -0
  163. package/src/tools/device-tools.ts +96 -0
  164. package/src/tools/ds-tools.ts +395 -0
  165. package/src/tools/interaction-tools.ts +467 -0
  166. package/src/tools/metro-tools.ts +320 -0
  167. package/src/tools/navigation-tools.ts +71 -0
  168. package/src/tools/screenshot-tools.ts +698 -0
  169. package/src/tools/snapshot-tools.ts +585 -0
  170. package/src/utils/image-preprocess.ts +430 -0
  171. package/src/utils/retry.ts +51 -0
  172. package/src/visual/comparator.ts +191 -0
  173. package/src/visual/layout-analyzer.ts +283 -0
  174. package/src/visual/screenshot.ts +49 -0
  175. package/tsconfig.json +20 -0
@@ -0,0 +1,134 @@
1
+ import type { CdpClient, CdpMessage } from "./cdp-client.js";
2
+
3
+ /**
4
+ * Intercepts network requests from the React Native app via CDP's Network domain.
5
+ * Captures fetch/XHR requests, their responses, and timing information.
6
+ */
7
+
8
+ export interface NetworkRequest {
9
+ id: string;
10
+ url: string;
11
+ method: string;
12
+ status?: number;
13
+ statusText?: string;
14
+ type?: string;
15
+ startTime: number;
16
+ endTime?: number;
17
+ duration?: number;
18
+ requestHeaders?: Record<string, string>;
19
+ responseHeaders?: Record<string, string>;
20
+ size?: number;
21
+ error?: string;
22
+ }
23
+
24
+ export class NetworkInspector {
25
+ private requests: Map<string, NetworkRequest> = new Map();
26
+ private maxRequests: number = 200;
27
+
28
+ constructor(private cdp: CdpClient) {}
29
+
30
+ /** Start intercepting network requests */
31
+ start(): void {
32
+ // Enable Network domain
33
+ this.cdp.send("Network.enable").catch(() => {
34
+ // Network domain might not be available
35
+ });
36
+
37
+ // Request started
38
+ this.cdp.on("Network.requestWillBeSent", (msg: CdpMessage) => {
39
+ const params = msg.params;
40
+ if (!params) return;
41
+
42
+ const request: NetworkRequest = {
43
+ id: params.requestId,
44
+ url: params.request?.url || "",
45
+ method: params.request?.method || "GET",
46
+ type: params.type,
47
+ startTime: Date.now(),
48
+ requestHeaders: params.request?.headers,
49
+ };
50
+
51
+ this.requests.set(params.requestId, request);
52
+ this.trimRequests();
53
+ });
54
+
55
+ // Response received
56
+ this.cdp.on("Network.responseReceived", (msg: CdpMessage) => {
57
+ const params = msg.params;
58
+ if (!params) return;
59
+
60
+ const request = this.requests.get(params.requestId);
61
+ if (request) {
62
+ request.status = params.response?.status;
63
+ request.statusText = params.response?.statusText;
64
+ request.responseHeaders = params.response?.headers;
65
+ request.endTime = Date.now();
66
+ request.duration = request.endTime - request.startTime;
67
+ }
68
+ });
69
+
70
+ // Loading finished
71
+ this.cdp.on("Network.loadingFinished", (msg: CdpMessage) => {
72
+ const params = msg.params;
73
+ if (!params) return;
74
+
75
+ const request = this.requests.get(params.requestId);
76
+ if (request) {
77
+ request.size = params.encodedDataLength;
78
+ if (!request.endTime) {
79
+ request.endTime = Date.now();
80
+ request.duration = request.endTime - request.startTime;
81
+ }
82
+ }
83
+ });
84
+
85
+ // Loading failed
86
+ this.cdp.on("Network.loadingFailed", (msg: CdpMessage) => {
87
+ const params = msg.params;
88
+ if (!params) return;
89
+
90
+ const request = this.requests.get(params.requestId);
91
+ if (request) {
92
+ request.error = params.errorText || "Request failed";
93
+ request.endTime = Date.now();
94
+ request.duration = request.endTime - request.startTime;
95
+ }
96
+ });
97
+ }
98
+
99
+ /** Get all captured network requests, optionally filtered by URL pattern */
100
+ getRequests(urlPattern?: string): NetworkRequest[] {
101
+ let requests = Array.from(this.requests.values());
102
+
103
+ if (urlPattern) {
104
+ const pattern = urlPattern.toLowerCase();
105
+ requests = requests.filter((r) =>
106
+ r.url.toLowerCase().includes(pattern)
107
+ );
108
+ }
109
+
110
+ // Sort by start time (newest first)
111
+ return requests.sort((a, b) => b.startTime - a.startTime);
112
+ }
113
+
114
+ /** Clear all captured requests */
115
+ clear(): void {
116
+ this.requests.clear();
117
+ }
118
+
119
+ private trimRequests(): void {
120
+ if (this.requests.size > this.maxRequests) {
121
+ // Remove oldest entries
122
+ const entries = Array.from(this.requests.entries()).sort(
123
+ (a, b) => a[1].startTime - b[1].startTime
124
+ );
125
+ const toRemove = entries.slice(
126
+ 0,
127
+ entries.length - Math.floor(this.maxRequests * 0.8)
128
+ );
129
+ for (const [id] of toRemove) {
130
+ this.requests.delete(id);
131
+ }
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,200 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import { retry } from "../../utils/retry.js";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ /**
8
+ * ADB command wrapper — handles all communication with Android devices via ADB.
9
+ */
10
+ export class Adb {
11
+ private adbPath: string;
12
+
13
+ constructor(private deviceId: string) {
14
+ const androidHome =
15
+ process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
16
+ this.adbPath = androidHome
17
+ ? `${androidHome}/platform-tools/adb`
18
+ : "adb";
19
+ }
20
+
21
+ /** Execute an ADB shell command on the device (with retry for transient failures) */
22
+ async shell(command: string): Promise<string> {
23
+ return retry(
24
+ async () => {
25
+ const { stdout } = await execFileAsync(
26
+ this.adbPath,
27
+ ["-s", this.deviceId, "shell", command],
28
+ { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }
29
+ );
30
+ return stdout;
31
+ },
32
+ { maxAttempts: 2, delayMs: 500 }
33
+ );
34
+ }
35
+
36
+ /** Execute an ADB command (non-shell) */
37
+ async exec(...args: string[]): Promise<string> {
38
+ const { stdout } = await execFileAsync(
39
+ this.adbPath,
40
+ ["-s", this.deviceId, ...args],
41
+ { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }
42
+ );
43
+ return stdout;
44
+ }
45
+
46
+ /** Take a screenshot and return the PNG buffer (with retry) */
47
+ async screenshot(): Promise<Buffer> {
48
+ return retry(
49
+ async () => {
50
+ const { stdout } = await execFileAsync(
51
+ this.adbPath,
52
+ ["-s", this.deviceId, "exec-out", "screencap", "-p"],
53
+ { maxBuffer: 20 * 1024 * 1024, encoding: "buffer", timeout: 15000 }
54
+ );
55
+ return stdout as unknown as Buffer;
56
+ },
57
+ { maxAttempts: 2, delayMs: 300 }
58
+ );
59
+ }
60
+
61
+ /** Dump the UI hierarchy XML (with retry) */
62
+ async dumpUiHierarchy(): Promise<string> {
63
+ return retry(
64
+ async () => {
65
+ const dumpPath = "/sdcard/devlens-ui-dump.xml";
66
+ await execFileAsync(
67
+ this.adbPath,
68
+ ["-s", this.deviceId, "shell", `uiautomator dump ${dumpPath}`],
69
+ { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }
70
+ );
71
+ const { stdout: xml } = await execFileAsync(
72
+ this.adbPath,
73
+ ["-s", this.deviceId, "shell", `cat ${dumpPath}`],
74
+ { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }
75
+ );
76
+ // Clean up
77
+ execFileAsync(
78
+ this.adbPath,
79
+ ["-s", this.deviceId, "shell", `rm ${dumpPath}`]
80
+ ).catch(() => {});
81
+ return xml;
82
+ },
83
+ { maxAttempts: 2, delayMs: 500 }
84
+ );
85
+ }
86
+
87
+ /** Tap at screen coordinates */
88
+ async tap(x: number, y: number): Promise<void> {
89
+ await this.shell(`input tap ${Math.round(x)} ${Math.round(y)}`);
90
+ }
91
+
92
+ /** Long press at coordinates */
93
+ async longPress(x: number, y: number, durationMs: number = 1000): Promise<void> {
94
+ await this.shell(
95
+ `input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${durationMs}`
96
+ );
97
+ }
98
+
99
+ /** Swipe between two points */
100
+ async swipe(
101
+ startX: number,
102
+ startY: number,
103
+ endX: number,
104
+ endY: number,
105
+ durationMs: number = 300
106
+ ): Promise<void> {
107
+ await this.shell(
108
+ `input swipe ${Math.round(startX)} ${Math.round(startY)} ${Math.round(endX)} ${Math.round(endY)} ${durationMs}`
109
+ );
110
+ }
111
+
112
+ /** Type text using ADB input */
113
+ async typeText(text: string): Promise<void> {
114
+ // Escape special characters for ADB shell
115
+ const escaped = text
116
+ .replace(/\\/g, "\\\\")
117
+ .replace(/"/g, '\\"')
118
+ .replace(/ /g, "%s")
119
+ .replace(/'/g, "\\'")
120
+ .replace(/&/g, "\\&")
121
+ .replace(/</g, "\\<")
122
+ .replace(/>/g, "\\>")
123
+ .replace(/\|/g, "\\|")
124
+ .replace(/;/g, "\\;")
125
+ .replace(/\(/g, "\\(")
126
+ .replace(/\)/g, "\\)");
127
+ await this.shell(`input text "${escaped}"`);
128
+ }
129
+
130
+ /** Press a key event */
131
+ async keyEvent(keyCode: number): Promise<void> {
132
+ await this.shell(`input keyevent ${keyCode}`);
133
+ }
134
+
135
+ /** Get device screen size */
136
+ async getScreenSize(): Promise<{ width: number; height: number }> {
137
+ const output = await this.shell("wm size");
138
+ const match = output.match(/(\d+)x(\d+)/);
139
+ if (!match) throw new Error("Could not determine screen size");
140
+ return { width: parseInt(match[1]), height: parseInt(match[2]) };
141
+ }
142
+
143
+ /** Get device orientation (0=portrait, 1=landscape, 2=reverse-portrait, 3=reverse-landscape) */
144
+ async getOrientation(): Promise<number> {
145
+ const output = await this.shell(
146
+ "dumpsys input | grep 'SurfaceOrientation'"
147
+ );
148
+ const match = output.match(/SurfaceOrientation:\s*(\d)/);
149
+ return match ? parseInt(match[1]) : 0;
150
+ }
151
+
152
+ /** Get Android version */
153
+ async getAndroidVersion(): Promise<string> {
154
+ return (await this.shell("getprop ro.build.version.release")).trim();
155
+ }
156
+
157
+ /** Get device model name */
158
+ async getDeviceName(): Promise<string> {
159
+ return (await this.shell("getprop ro.product.model")).trim();
160
+ }
161
+
162
+ /** Launch an app by package name */
163
+ async launchApp(packageName: string): Promise<void> {
164
+ await this.shell(
165
+ `monkey -p ${packageName} -c android.intent.category.LAUNCHER 1`
166
+ );
167
+ }
168
+
169
+ /** Force stop an app */
170
+ async terminateApp(packageName: string): Promise<void> {
171
+ await this.shell(`am force-stop ${packageName}`);
172
+ }
173
+
174
+ /** Install an APK */
175
+ async installApp(apkPath: string): Promise<void> {
176
+ await this.exec("install", "-r", apkPath);
177
+ }
178
+
179
+ /** List installed packages */
180
+ async listPackages(): Promise<string[]> {
181
+ const output = await this.shell("pm list packages -3");
182
+ return output
183
+ .split("\n")
184
+ .filter((line) => line.startsWith("package:"))
185
+ .map((line) => line.replace("package:", "").trim());
186
+ }
187
+
188
+ /** Open a URL */
189
+ async openUrl(url: string): Promise<void> {
190
+ await this.shell(
191
+ `am start -a android.intent.action.VIEW -d "${url}"`
192
+ );
193
+ }
194
+
195
+ /** Set orientation (0=auto, 1=portrait, 2=landscape) */
196
+ async setRotation(rotation: number): Promise<void> {
197
+ await this.shell("settings put system accelerometer_rotation 0");
198
+ await this.shell(`settings put system user_rotation ${rotation}`);
199
+ }
200
+ }
@@ -0,0 +1,116 @@
1
+ import type { Device, DeviceInfo, AppInfo, SnapshotNode } from "../device.js";
2
+ import { Adb } from "./adb.js";
3
+ import { parseUiAutomatorXml } from "./ui-automator.js";
4
+
5
+ // Android key codes
6
+ const KEY_HOME = 3;
7
+ const KEY_BACK = 4;
8
+ const KEY_ENTER = 66;
9
+
10
+ export class AndroidDevice implements Device {
11
+ private adb: Adb;
12
+
13
+ constructor(deviceId: string) {
14
+ this.adb = new Adb(deviceId);
15
+ }
16
+
17
+ async getInfo(): Promise<DeviceInfo> {
18
+ const [screenSize, orientation, version, name] = await Promise.all([
19
+ this.adb.getScreenSize(),
20
+ this.adb.getOrientation(),
21
+ this.adb.getAndroidVersion(),
22
+ this.adb.getDeviceName(),
23
+ ]);
24
+
25
+ return {
26
+ id: (this.adb as any).deviceId,
27
+ name,
28
+ platform: "android",
29
+ osVersion: version,
30
+ screenWidth: screenSize.width,
31
+ screenHeight: screenSize.height,
32
+ orientation: orientation === 0 || orientation === 2 ? "portrait" : "landscape",
33
+ isBooted: true,
34
+ };
35
+ }
36
+
37
+ async getSnapshot(): Promise<SnapshotNode> {
38
+ const xml = await this.adb.dumpUiHierarchy();
39
+ return parseUiAutomatorXml(xml);
40
+ }
41
+
42
+ async takeScreenshot(): Promise<Buffer> {
43
+ return this.adb.screenshot();
44
+ }
45
+
46
+ async tap(x: number, y: number): Promise<void> {
47
+ await this.adb.tap(x, y);
48
+ }
49
+
50
+ async doubleTap(x: number, y: number): Promise<void> {
51
+ await this.adb.tap(x, y);
52
+ await sleep(100);
53
+ await this.adb.tap(x, y);
54
+ }
55
+
56
+ async longPress(x: number, y: number, durationMs = 1000): Promise<void> {
57
+ await this.adb.longPress(x, y, durationMs);
58
+ }
59
+
60
+ async typeText(text: string): Promise<void> {
61
+ await this.adb.typeText(text);
62
+ }
63
+
64
+ async swipe(
65
+ startX: number,
66
+ startY: number,
67
+ endX: number,
68
+ endY: number,
69
+ durationMs = 300
70
+ ): Promise<void> {
71
+ await this.adb.swipe(startX, startY, endX, endY, durationMs);
72
+ }
73
+
74
+ async pressButton(button: "home" | "back" | "enter"): Promise<void> {
75
+ const keyMap: Record<string, number> = {
76
+ home: KEY_HOME,
77
+ back: KEY_BACK,
78
+ enter: KEY_ENTER,
79
+ };
80
+ await this.adb.keyEvent(keyMap[button]);
81
+ }
82
+
83
+ async setOrientation(orientation: "portrait" | "landscape"): Promise<void> {
84
+ const rotation = orientation === "portrait" ? 0 : 1;
85
+ await this.adb.setRotation(rotation);
86
+ }
87
+
88
+ async launchApp(appId: string): Promise<void> {
89
+ await this.adb.launchApp(appId);
90
+ }
91
+
92
+ async terminateApp(appId: string): Promise<void> {
93
+ await this.adb.terminateApp(appId);
94
+ }
95
+
96
+ async installApp(path: string): Promise<void> {
97
+ await this.adb.installApp(path);
98
+ }
99
+
100
+ async listApps(): Promise<AppInfo[]> {
101
+ const packages = await this.adb.listPackages();
102
+ return packages.map((pkg) => ({
103
+ appId: pkg,
104
+ name: pkg,
105
+ isRunning: false, // Would need `ps` to determine
106
+ }));
107
+ }
108
+
109
+ async openUrl(url: string): Promise<void> {
110
+ await this.adb.openUrl(url);
111
+ }
112
+ }
113
+
114
+ function sleep(ms: number): Promise<void> {
115
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
+ }
@@ -0,0 +1,141 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import type { SnapshotNode, Bounds } from "../device.js";
3
+
4
+ /**
5
+ * Parses Android UI Automator XML dump into SnapshotNode tree.
6
+ *
7
+ * The XML format from `uiautomator dump` looks like:
8
+ * <hierarchy rotation="0">
9
+ * <node index="0" text="" resource-id="..." class="android.widget.FrameLayout"
10
+ * package="com.example" content-desc="" checkable="false" checked="false"
11
+ * clickable="false" enabled="true" focusable="false" focused="false"
12
+ * scrollable="false" long-clickable="false" password="false" selected="false"
13
+ * bounds="[0,0][1080,2340]">
14
+ * <node ... />
15
+ * </node>
16
+ * </hierarchy>
17
+ */
18
+ export function parseUiAutomatorXml(xml: string): SnapshotNode {
19
+ const parser = new XMLParser({
20
+ ignoreAttributes: false,
21
+ attributeNamePrefix: "@_",
22
+ allowBooleanAttributes: true,
23
+ isArray: (name) => name === "node",
24
+ });
25
+
26
+ const parsed = parser.parse(xml);
27
+ const hierarchy = parsed.hierarchy;
28
+
29
+ if (!hierarchy || !hierarchy.node) {
30
+ throw new Error(
31
+ "Invalid UI Automator dump: no hierarchy or root node found"
32
+ );
33
+ }
34
+
35
+ const rootNodes: any[] = Array.isArray(hierarchy.node)
36
+ ? hierarchy.node
37
+ : [hierarchy.node];
38
+
39
+ // Create a virtual root that contains all top-level nodes
40
+ return {
41
+ type: "hierarchy",
42
+ text: undefined,
43
+ label: undefined,
44
+ description: undefined,
45
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
46
+ interactive: false,
47
+ children: rootNodes.map(convertNode),
48
+ };
49
+ }
50
+
51
+ function convertNode(node: any): SnapshotNode {
52
+ const bounds = parseBounds(node["@_bounds"] || "[0,0][0,0]");
53
+ const text = node["@_text"] || undefined;
54
+ const description = node["@_content-desc"] || undefined;
55
+ const resourceId = node["@_resource-id"] || undefined;
56
+ const className = node["@_class"] || "unknown";
57
+
58
+ const clickable = node["@_clickable"] === "true";
59
+ const longClickable = node["@_long-clickable"] === "true";
60
+ const checkable = node["@_checkable"] === "true";
61
+ const scrollable = node["@_scrollable"] === "true";
62
+ const focusable = node["@_focusable"] === "true";
63
+ const interactive =
64
+ clickable || longClickable || checkable || scrollable || focusable;
65
+
66
+ const children: SnapshotNode[] = [];
67
+ if (node.node) {
68
+ const childNodes = Array.isArray(node.node) ? node.node : [node.node];
69
+ for (const child of childNodes) {
70
+ children.push(convertNode(child));
71
+ }
72
+ }
73
+
74
+ return {
75
+ type: simplifyClassName(className),
76
+ text: text && text.length > 0 ? text : undefined,
77
+ label: description && description.length > 0 ? description : undefined,
78
+ description:
79
+ description && description.length > 0 ? description : undefined,
80
+ bounds,
81
+ interactive,
82
+ focused: node["@_focused"] === "true",
83
+ enabled: node["@_enabled"] === "true",
84
+ visible: bounds.width > 0 && bounds.height > 0,
85
+ value: getElementValue(node),
86
+ resourceId: resourceId && resourceId.length > 0 ? resourceId : undefined,
87
+ children,
88
+ rawAttributes: extractRawAttributes(node),
89
+ };
90
+ }
91
+
92
+ /** Parse Android bounds format "[left,top][right,bottom]" into Bounds */
93
+ function parseBounds(boundsStr: string): Bounds {
94
+ const match = boundsStr.match(
95
+ /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/
96
+ );
97
+ if (!match) {
98
+ return { x: 0, y: 0, width: 0, height: 0 };
99
+ }
100
+
101
+ const left = parseInt(match[1]);
102
+ const top = parseInt(match[2]);
103
+ const right = parseInt(match[3]);
104
+ const bottom = parseInt(match[4]);
105
+
106
+ return {
107
+ x: left,
108
+ y: top,
109
+ width: right - left,
110
+ height: bottom - top,
111
+ };
112
+ }
113
+
114
+ /** Simplify Android class names for readability */
115
+ function simplifyClassName(className: string): string {
116
+ // "android.widget.TextView" → "TextView"
117
+ // "android.view.View" → "View"
118
+ const parts = className.split(".");
119
+ return parts[parts.length - 1];
120
+ }
121
+
122
+ /** Extract element value (for checkboxes, switches, inputs, etc.) */
123
+ function getElementValue(node: any): string | undefined {
124
+ if (node["@_checked"] === "true") return "checked";
125
+ if (node["@_checked"] === "false" && node["@_checkable"] === "true")
126
+ return "unchecked";
127
+ if (node["@_selected"] === "true") return "selected";
128
+ if (node["@_password"] === "true") return "•••";
129
+ return undefined;
130
+ }
131
+
132
+ /** Extract all raw attributes for debugging */
133
+ function extractRawAttributes(node: any): Record<string, string> {
134
+ const attrs: Record<string, string> = {};
135
+ for (const key of Object.keys(node)) {
136
+ if (key.startsWith("@_")) {
137
+ attrs[key.replace("@_", "")] = String(node[key]);
138
+ }
139
+ }
140
+ return attrs;
141
+ }