mobile-debug-mcp 0.14.0 → 0.16.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 (103) hide show
  1. package/dist/android/interact.js +2 -2
  2. package/dist/android/observe.js +13 -0
  3. package/dist/cli/ios/run-ios-smoke.js +2 -2
  4. package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
  5. package/dist/interact/android.js +91 -0
  6. package/dist/interact/index.js +76 -0
  7. package/dist/interact/ios.js +120 -0
  8. package/dist/interact/shared/fingerprint.js +1 -0
  9. package/dist/interact/shared/scroll_to_element.js +1 -0
  10. package/dist/ios/interact.js +2 -2
  11. package/dist/ios/observe.js +12 -0
  12. package/dist/manage/android.js +162 -0
  13. package/dist/manage/index.js +364 -0
  14. package/dist/manage/ios.js +353 -0
  15. package/dist/observe/android.js +351 -0
  16. package/dist/observe/fingerprint.js +1 -0
  17. package/dist/observe/index.js +85 -0
  18. package/dist/observe/ios.js +320 -0
  19. package/dist/observe/test/device/logstream-real.js +34 -0
  20. package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
  21. package/dist/observe/test/device/run-scroll-test-android.js +22 -0
  22. package/dist/observe/test/device/test-ui-tree.js +67 -0
  23. package/dist/observe/test/device/wait_for_element_real.js +69 -0
  24. package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
  25. package/dist/observe/test/unit/logparse.test.js +39 -0
  26. package/dist/observe/test/unit/logstream.test.js +41 -0
  27. package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
  28. package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
  29. package/dist/server.js +41 -5
  30. package/dist/shared/fingerprint.js +72 -0
  31. package/dist/shared/scroll_to_element.js +98 -0
  32. package/dist/tools/interact.js +2 -2
  33. package/dist/tools/manage.js +2 -2
  34. package/dist/tools/observe.js +45 -43
  35. package/dist/utils/android/utils.js +373 -0
  36. package/dist/utils/cli/idb/check-idb.js +84 -0
  37. package/dist/utils/cli/idb/idb-helper.js +91 -0
  38. package/dist/utils/cli/idb/install-idb.js +82 -0
  39. package/dist/utils/cli/ios/preflight-ios.js +155 -0
  40. package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
  41. package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
  42. package/dist/utils/diagnostics.js +1 -1
  43. package/dist/utils/exec.js +34 -0
  44. package/dist/utils/ios/utils.js +301 -0
  45. package/dist/utils/resolve-device.js +2 -2
  46. package/dist/utils/ui/index.js +169 -0
  47. package/docs/CHANGELOG.md +8 -0
  48. package/docs/tools/interact.md +29 -0
  49. package/docs/tools/observe.md +24 -0
  50. package/package.json +1 -1
  51. package/src/{android/interact.ts → interact/android.ts} +3 -3
  52. package/src/{tools/interact.ts → interact/index.ts} +47 -3
  53. package/src/{ios/interact.ts → interact/ios.ts} +3 -3
  54. package/src/{android/manage.ts → manage/android.ts} +2 -2
  55. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  56. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  57. package/src/{android/observe.ts → observe/android.ts} +14 -26
  58. package/src/observe/index.ts +92 -0
  59. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  60. package/src/server.ts +45 -6
  61. package/src/types.ts +1 -0
  62. package/src/{android → utils/android}/utils.ts +12 -79
  63. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  64. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  65. package/src/utils/diagnostics.ts +1 -1
  66. package/src/utils/exec.ts +33 -0
  67. package/src/{ios → utils/ios}/utils.ts +2 -2
  68. package/src/utils/resolve-device.ts +2 -2
  69. package/src/{tools/scroll_to_element.ts → utils/ui/index.ts} +73 -2
  70. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  71. package/test/interact/unit/wait_for_screen_change.test.ts +32 -0
  72. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  73. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  74. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  76. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  77. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  78. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  79. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  80. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  81. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  82. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  83. package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
  84. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  85. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  86. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  87. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  88. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  89. package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
  90. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
  91. package/test/unit/index.ts +13 -11
  92. package/src/tools/observe.ts +0 -82
  93. package/test/device/README.md +0 -49
  94. package/test/device/index.ts +0 -27
  95. package/test/device/utils/test-dist.ts +0 -41
  96. package/test/unit/utils/detect-java.test.ts +0 -22
  97. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  98. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  99. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  100. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  101. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  102. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  103. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -1,12 +1,11 @@
1
1
  import { spawn } from "child_process"
2
2
  import { promises as fs } from "fs"
3
3
  import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo } from "../types.js"
4
- import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js"
4
+ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
5
5
  import { createWriteStream, promises as fsPromises } from 'fs'
6
6
  import path from 'path'
7
- import { parseLogLine } from '../android/utils.js'
8
-
9
- // --- Helper Functions Specific to Observe ---
7
+ import { parseLogLine } from '../utils/android/utils.js'
8
+ import { computeScreenFingerprint } from '../utils/ui/index.js'
10
9
 
11
10
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
12
11
 
@@ -25,7 +24,6 @@ interface IDBElement {
25
24
 
26
25
  function parseIDBFrame(frame: any): [number, number, number, number] {
27
26
  if (!frame) return [0, 0, 0, 0];
28
- // Handle string frames like "{{0, 0}, {402, 874}}"
29
27
  if (typeof frame === 'string') {
30
28
  const nums = frame.match(/-?\d+(?:\.\d+)?/g);
31
29
  if (!nums || nums.length < 4) return [0, 0, 0, 0];
@@ -53,29 +51,25 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
53
51
 
54
52
  let currentIndex = -1;
55
53
 
56
- // Prefer standard keys, fallback to alternatives
57
54
  const type = node.AXElementType || node.type || "unknown";
58
55
  const label = node.AXLabel || node.label || null;
59
56
  const value = node.AXValue || null;
60
57
  const frame = node.AXFrame || node.frame;
61
58
  const traits = node.AXTraits || [];
62
59
 
63
- const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell"; // Cells are often clickable
60
+ const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell";
64
61
 
65
- // Filtering Logic:
66
- // Keep if clickable OR has visible text/label OR has value
67
- // Also keep 'Window' or 'Application' types as they define the root structure often, though usually depth 0
68
62
  const isUseful = clickable || (label && label.length > 0) || (value && value.length > 0) || type === "Application" || type === "Window";
69
63
 
70
64
  if (isUseful) {
71
65
  const bounds = parseIDBFrame(frame);
72
66
  const element: UIElement = {
73
67
  text: label,
74
- contentDescription: value,
68
+ contentDescription: value,
75
69
  type: type,
76
70
  resourceId: node.AXUniqueId || null,
77
71
  clickable: clickable,
78
- enabled: true, // idb usually returns enabled elements
72
+ enabled: true,
79
73
  visible: true,
80
74
  bounds: bounds,
81
75
  center: getCenter(bounds),
@@ -90,8 +84,6 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
90
84
  currentIndex = elements.length - 1;
91
85
  }
92
86
 
93
- // If current node was skipped, children inherit parentIndex and depth (flattening)
94
- // If current node was added, children use currentIndex and depth + 1
95
87
  const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
96
88
  const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
97
89
 
@@ -113,12 +105,8 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
113
105
  return currentIndex;
114
106
  }
115
107
 
116
-
117
-
118
- // iOS live log stream support (moved from ios/utils to observe)
119
108
  const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
120
109
 
121
- // Test helpers
122
110
  export function _setIOSActiveLogStream(sessionId: string, file: string) {
123
111
  iosActiveLogStreams.set(sessionId, { proc: {} as any, file })
124
112
  }
@@ -133,9 +121,6 @@ export class iOSObserve {
133
121
  }
134
122
 
135
123
  async getLogs(appId?: string, deviceId: string = "booted"): Promise<GetLogsResponse> {
136
- // If appId is provided, use predicate filtering
137
- // Note: execFile passes args directly, so we don't need shell escaping for the predicate string itself,
138
- // but we do need to construct the predicate correctly for log show.
139
124
  const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m']
140
125
  if (appId) {
141
126
  validateBundleId(appId)
@@ -157,25 +142,19 @@ export class iOSObserve {
157
142
  const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`
158
143
 
159
144
  try {
160
- // 1. Capture screenshot to temp file
161
145
  await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
162
146
 
163
- // 2. Read file as base64
164
147
  const buffer = await fs.readFile(tmpFile)
165
148
  const base64 = buffer.toString('base64')
166
149
 
167
- // 3. Clean up
168
150
  await fs.rm(tmpFile).catch(() => {})
169
151
 
170
152
  return {
171
153
  device,
172
154
  screenshot: base64,
173
- // Default resolution since we can't easily parse it without extra libs
174
- // Clients will read the real dimensions from the PNG header anyway
175
155
  resolution: { width: 0, height: 0 },
176
156
  }
177
157
  } catch (e) {
178
- // Ensure cleanup happens even on error
179
158
  await fs.rm(tmpFile).catch(() => {})
180
159
  throw new Error(`Failed to capture screenshot: ${e instanceof Error ? e.message : String(e)}`)
181
160
  }
@@ -184,7 +163,6 @@ export class iOSObserve {
184
163
  async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
185
164
  const device = await getIOSDeviceMetadata(deviceId);
186
165
 
187
- // idb is required
188
166
  const idbExists = await isIDBInstalled();
189
167
  if (!idbExists) {
190
168
  return {
@@ -196,10 +174,8 @@ export class iOSObserve {
196
174
  };
197
175
  }
198
176
 
199
- // Resolve UDID if needed
200
177
  const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
201
178
 
202
- // Retry Logic
203
179
  let jsonContent: any = null;
204
180
  let attempts = 0;
205
181
  const maxAttempts = 3;
@@ -207,7 +183,6 @@ export class iOSObserve {
207
183
  while (attempts < maxAttempts) {
208
184
  attempts++;
209
185
  try {
210
- // Stabilization delay
211
186
  await delay(300 + (attempts * 100));
212
187
 
213
188
  const args = ['ui', 'describe-all', '--json'];
@@ -255,7 +230,6 @@ export class iOSObserve {
255
230
 
256
231
  try {
257
232
  const elements: UIElement[] = [];
258
- // idb describe-all returns either a root object or an array of root nodes
259
233
  if (Array.isArray(jsonContent)) {
260
234
  for (const node of jsonContent) {
261
235
  traverseIDBNode(node, elements);
@@ -264,7 +238,6 @@ export class iOSObserve {
264
238
  traverseIDBNode(jsonContent, elements);
265
239
  }
266
240
 
267
- // Infer resolution from root element if possible (usually the Window/Application frame)
268
241
  let width = 0;
269
242
  let height = 0;
270
243
  if (elements.length > 0) {
@@ -290,7 +263,17 @@ export class iOSObserve {
290
263
  }
291
264
  }
292
265
 
293
- // --- Log stream methods ---
266
+ async getScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string }> {
267
+ try {
268
+ const tree = await this.getUITree(deviceId)
269
+ if (!tree || tree.error) return { fingerprint: null, error: tree && (tree as any).error }
270
+
271
+ return computeScreenFingerprint(tree, null, 'ios', 50)
272
+ } catch (e) {
273
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
274
+ }
275
+ }
276
+
294
277
  async startLogStream(bundleId: string, deviceId: string = 'booted', sessionId: string = 'default') : Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
295
278
  try {
296
279
  const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`
@@ -303,7 +286,6 @@ export class iOSObserve {
303
286
  const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate]
304
287
  const proc = spawn(getXcrunCmd(), args)
305
288
 
306
- // Prepare output file
307
289
  const tmpDir = process.env.TMPDIR || '/tmp'
308
290
  const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`)
309
291
  const stream = createWriteStream(file, { flags: 'a' })
package/src/server.ts CHANGED
@@ -14,11 +14,11 @@ import {
14
14
  InstallAppResponse
15
15
  } from "./types.js"
16
16
 
17
- import { ToolsManage } from './tools/manage.js'
18
- import { ToolsInteract } from './tools/interact.js'
19
- import { ToolsObserve } from './tools/observe.js'
20
- import { AndroidManage } from './android/manage.js'
21
- import { iOSManage } from './ios/manage.js'
17
+ import { ToolsManage } from './manage/index.js'
18
+ import { ToolsInteract } from './interact/index.js'
19
+ import { ToolsObserve } from './observe/index.js'
20
+ import { AndroidManage } from './manage/index.js'
21
+ import { iOSManage } from './manage/index.js'
22
22
 
23
23
 
24
24
  const server = new Server(
@@ -283,6 +283,32 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
283
283
  }
284
284
  }
285
285
  },
286
+ {
287
+ name: "get_screen_fingerprint",
288
+ description: "Generate a stable fingerprint representing the current visible screen (activity + visible UI elements).",
289
+ inputSchema: {
290
+ type: "object",
291
+ properties: {
292
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
293
+ deviceId: { type: "string", description: "Optional device id/udid to target" }
294
+ }
295
+ }
296
+ },
297
+ {
298
+ name: "wait_for_screen_change",
299
+ description: "Wait until the current screen fingerprint differs from a provided previousFingerprint. Useful to wait for navigation/animation completion.",
300
+ inputSchema: {
301
+ type: "object",
302
+ properties: {
303
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
304
+ previousFingerprint: { type: "string", description: "The fingerprint to compare against (required)" },
305
+ timeoutMs: { type: "number", description: "Timeout in ms to wait for change (default 5000)", default: 5000 },
306
+ pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300)", default: 300 },
307
+ deviceId: { type: "string", description: "Optional device id/udid to target" }
308
+ },
309
+ required: ["previousFingerprint"]
310
+ }
311
+ },
286
312
  {
287
313
  name: "wait_for_element",
288
314
  description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
@@ -311,6 +337,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
311
337
  required: ["platform", "text"]
312
338
  }
313
339
  },
340
+
314
341
  {
315
342
  name: "tap",
316
343
  description: "Simulate a finger tap on the device screen at specific coordinates.",
@@ -579,6 +606,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
579
606
  return wrapResponse(res)
580
607
  }
581
608
 
609
+ if (name === "get_screen_fingerprint") {
610
+ const { platform, deviceId } = (args || {}) as any
611
+ const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId })
612
+ return wrapResponse(res)
613
+ }
614
+
615
+ if (name === "wait_for_screen_change") {
616
+ const { platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId } = (args || {}) as any
617
+ const res = await ToolsInteract.waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId })
618
+ return wrapResponse(res)
619
+ }
620
+
582
621
  if (name === "wait_for_element") {
583
622
  const { platform, text, timeout, deviceId } = (args || {}) as any
584
623
  const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId })
@@ -644,7 +683,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
644
683
  const transport = new StdioServerTransport()
645
684
 
646
685
  async function main() {
647
- await server.connect(transport)
686
+ await (server as any).connect(transport)
648
687
  }
649
688
 
650
689
  main().catch((error) => {
package/src/types.ts CHANGED
@@ -143,3 +143,4 @@ export interface InstallAppResponse {
143
143
  error?: string;
144
144
  diagnostics?: any;
145
145
  }
146
+
@@ -1,8 +1,8 @@
1
- import { spawn } from 'child_process'
2
- import { DeviceInfo, UIElement } from "../types.js"
1
+ import { DeviceInfo, UIElement } from "../../types.js"
3
2
  import { promises as fsPromises, existsSync } from 'fs'
4
3
  import path from 'path'
5
- import { detectJavaHome } from '../utils/java.js'
4
+ import { detectJavaHome } from '../java.js'
5
+ import { execCmd } from '../exec.js'
6
6
 
7
7
  export function getAdbCmd() { return process.env.ADB_PATH || 'adb' }
8
8
 
@@ -83,87 +83,20 @@ import type { SpawnOptions } from 'child_process'
83
83
 
84
84
  export type SpawnOptionsWithTimeout = SpawnOptions & { timeout?: number }
85
85
 
86
- export function execAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<string> {
86
+ export async function execAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<string> {
87
87
  const adbArgs = getAdbArgs(args, deviceId)
88
- return new Promise((resolve, reject) => {
89
- // Extract timeout from options if present, otherwise pass options to spawn
90
- const { timeout: customTimeout, ...spawnOptions } = options;
91
-
92
- // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
93
- const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
94
-
95
- let stdout = ''
96
- let stderr = ''
97
-
98
- if (child.stdout) {
99
- child.stdout.on('data', (data) => {
100
- stdout += data.toString()
101
- })
102
- }
103
-
104
- if (child.stderr) {
105
- child.stderr.on('data', (data) => {
106
- stderr += data.toString()
107
- })
108
- }
109
-
110
- const timeoutMs = getAdbTimeout(args, customTimeout)
111
-
112
-
113
- const timeout = setTimeout(() => {
114
- child.kill()
115
- reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`))
116
- }, timeoutMs)
117
-
118
- child.on('close', (code) => {
119
- clearTimeout(timeout)
120
- if (code !== 0) {
121
- // If there's an actual error (non-zero exit code), reject
122
- reject(new Error(stderr.trim() || `Command failed with code ${code}`))
123
- } else {
124
- // If exit code is 0, resolve with stdout
125
- resolve(stdout.trim())
126
- }
127
- })
128
-
129
- child.on('error', (err) => {
130
- clearTimeout(timeout)
131
- reject(err)
132
- })
133
- })
88
+ const timeoutMs = getAdbTimeout(args, options.timeout)
89
+ const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env as any, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell })
90
+ if (res.exitCode !== 0) throw new Error(res.stderr || `Command failed with code ${res.exitCode}`)
91
+ return res.stdout
134
92
  }
135
93
 
136
94
  // Spawn adb but return full streams and exit code so callers can implement fallbacks or stream output
137
- export function spawnAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<{ stdout: string, stderr: string, code: number | null }> {
95
+ export async function spawnAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<{ stdout: string, stderr: string, code: number | null }> {
138
96
  const adbArgs = getAdbArgs(args, deviceId)
139
- return new Promise((resolve, reject) => {
140
- const { timeout: customTimeout, ...spawnOptions } = options
141
- const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
142
-
143
- let stdout = ''
144
- let stderr = ''
145
-
146
- if (child.stdout) child.stdout.on('data', d => { stdout += d.toString() })
147
- if (child.stderr) child.stderr.on('data', d => { stderr += d.toString() })
148
-
149
- const timeoutMs = getAdbTimeout(args, customTimeout)
150
-
151
-
152
- const timeout = setTimeout(() => {
153
- try { child.kill() } catch {}
154
- reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`))
155
- }, timeoutMs)
156
-
157
- child.on('close', (code) => {
158
- clearTimeout(timeout)
159
- resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code })
160
- })
161
-
162
- child.on('error', (err) => {
163
- clearTimeout(timeout)
164
- reject(err)
165
- })
166
- })
97
+ const timeoutMs = getAdbTimeout(args, options.timeout)
98
+ const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env as any, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell })
99
+ return { stdout: res.stdout, stderr: res.stderr, code: res.exitCode }
167
100
  }
168
101
 
169
102
  export function getDeviceInfo(deviceId: string, metadata: Partial<DeviceInfo> = {}): DeviceInfo {
@@ -1,5 +1,5 @@
1
- import { iOSObserve } from '../../ios/observe.js';
2
- import { iOSManage } from '../../ios/manage.js';
1
+ import { iOSObserve } from '../../../observe/index.js';
2
+ import { iOSManage } from '../../../manage/index.js';
3
3
 
4
4
  async function main() {
5
5
  const appId = process.argv[2] || 'com.apple.springboard';
@@ -1,5 +1,5 @@
1
- import { iOSObserve } from '../../ios/observe.js';
2
- import { iOSInteract } from '../../ios/interact.js';
1
+ import { iOSObserve } from '../../../observe/index.js';
2
+ import { iOSInteract } from '../../../interact/index.js';
3
3
 
4
4
  async function main() {
5
5
  const deviceId = 'booted';
@@ -18,7 +18,7 @@ async function main() {
18
18
  process.exit(3);
19
19
  }
20
20
 
21
- const clickable = tree.elements.find(e => e.clickable) || tree.elements[0];
21
+ const clickable = tree.elements.find((e: any) => e.clickable) || tree.elements[0];
22
22
  console.log('Using element:', clickable.text || '(no text)', 'clickable=', clickable.clickable, 'center=', clickable.center);
23
23
  const [x,y] = clickable.center || [0,0];
24
24
 
@@ -37,7 +37,7 @@ export class DiagnosticError extends Error {
37
37
 
38
38
  // Exec ADB with diagnostics — moved from src/android/diagnostics.ts
39
39
  import { spawnSync } from 'child_process'
40
- import { getAdbCmd } from '../android/utils.js'
40
+ import { getAdbCmd } from './android/utils.js'
41
41
 
42
42
  export function execAdbWithDiagnostics(args: string[], deviceId?: string) {
43
43
  const adbArgs = deviceId ? ['-s', deviceId, ...args] : args
@@ -0,0 +1,33 @@
1
+ import { spawn } from 'child_process'
2
+
3
+ export type ExecOptions = { timeout?: number; env?: NodeJS.ProcessEnv; cwd?: string; shell?: boolean }
4
+
5
+ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {}): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
6
+ const { timeout = 0, env, cwd, shell } = opts
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell })
9
+ let stdout = ''
10
+ let stderr = ''
11
+ if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString() })
12
+ if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString() })
13
+
14
+ let timedOut = false
15
+ const timer = timeout && timeout > 0 ? setTimeout(() => {
16
+ timedOut = true
17
+ try { child.kill() } catch { }
18
+ resolve({ exitCode: null, stdout: stdout.trim(), stderr: stderr.trim() })
19
+ }, timeout) : null
20
+
21
+ child.on('close', (code) => {
22
+ if (timer) clearTimeout(timer)
23
+ if (timedOut) return
24
+ resolve({ exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() })
25
+ })
26
+
27
+ child.on('error', (err) => {
28
+ if (timer) clearTimeout(timer)
29
+ reject(err)
30
+ })
31
+ })
32
+ }
33
+
@@ -1,8 +1,8 @@
1
1
  import { execFile, spawn, execSync, spawnSync } from "child_process"
2
- import { DeviceInfo } from "../types.js"
2
+ import { DeviceInfo } from "../../types.js"
3
3
  import { promises as fsPromises } from 'fs'
4
4
  import path from 'path'
5
- import { makeEnvSnapshot } from '../utils/diagnostics.js'
5
+ import { makeEnvSnapshot } from '../diagnostics.js'
6
6
 
7
7
  export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun' }
8
8
 
@@ -1,6 +1,6 @@
1
1
  import { DeviceInfo } from "../types.js"
2
- import { listAndroidDevices } from "../android/utils.js"
3
- import { listIOSDevices } from "../ios/utils.js"
2
+ import { listAndroidDevices } from "./android/utils.js"
3
+ import { listIOSDevices } from "./ios/utils.js"
4
4
 
5
5
  export interface ResolveOptions {
6
6
  platform: "android" | "ios"
@@ -1,4 +1,76 @@
1
- import { UIElement, GetUITreeResponse, SwipeResponse } from '../types.js'
1
+ import crypto from 'crypto'
2
+ import { GetUITreeResponse, GetCurrentScreenResponse, UIElement, SwipeResponse } from '../../types.js'
3
+
4
+ const ANDROID_STRUCTURAL_TYPES = ['Window','Application','View','ViewGroup','LinearLayout','FrameLayout','RelativeLayout','ScrollView','RecyclerView','TextView','ImageView']
5
+ const IOS_STRUCTURAL_TYPES = ['Window','Application','View','ViewController','UITableView','UICollectionView','UILabel','UIImageView','UIView','UIWindow','UIStackView','UITextView','UITableViewCell']
6
+
7
+ function isDynamicText(t?: string): boolean {
8
+ if (!t) return false
9
+ const txt = t.trim()
10
+ if (!txt) return false
11
+ if (/\b\d{1,2}:\d{2}\b/.test(txt)) return true
12
+ if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt)) return true
13
+ if (/^\d+(?:\.\d+)?%$/.test(txt)) return true
14
+ if (/^\d+$/.test(txt)) return true
15
+ if (/^[\d,]{1,10}$/.test(txt)) return true
16
+ return false
17
+ }
18
+
19
+ function normalizeElement(e: UIElement) {
20
+ return {
21
+ type: (e.type || '').toString(),
22
+ resourceId: (e.resourceId || '').toString(),
23
+ text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
24
+ contentDesc: (e.contentDescription || '').toString(),
25
+ bounds: Array.isArray(e.bounds) ? e.bounds.slice(0,4).map((n:any)=>Number(n)||0) : [0,0,0,0]
26
+ }
27
+ }
28
+
29
+ export function computeScreenFingerprint(tree: GetUITreeResponse, current: GetCurrentScreenResponse | null, platform: 'android' | 'ios', limit: number = 50): { fingerprint: string | null; activity?: string; error?: string } {
30
+ try {
31
+ if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
32
+
33
+ const activity = current && (current.activity || (current as any).shortActivity) ? (current.activity || (current as any).shortActivity) : ''
34
+
35
+ const candidates: UIElement[] = (tree.elements || []).filter(e => {
36
+ if (!e) return false
37
+ if (!e.visible) return false
38
+ const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0
39
+ const hasResource = !!e.resourceId
40
+ const interactable = !!e.clickable || !!e.enabled
41
+ const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES
42
+ const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '')
43
+ return interactable || structurallySignificant
44
+ }) as UIElement[]
45
+
46
+ const normalized = candidates.map(normalizeElement)
47
+
48
+ const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0))
49
+
50
+ filteredNormalized.sort((a,b) => {
51
+ const ay = (a.bounds && a.bounds[1]) || 0
52
+ const by = (b.bounds && b.bounds[1]) || 0
53
+ if (ay !== by) return ay - by
54
+ const ax = (a.bounds && a.bounds[0]) || 0
55
+ const bx = (b.bounds && b.bounds[0]) || 0
56
+ return ax - bx
57
+ })
58
+
59
+ const limited = filteredNormalized.slice(0, Math.max(0, limit))
60
+
61
+ const payload = {
62
+ activity: platform === 'android' ? (activity || '') : '',
63
+ resolution: (tree as any).resolution || { width: 0, height: 0 },
64
+ elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
65
+ }
66
+
67
+ const combined = JSON.stringify(payload)
68
+ const hash = crypto.createHash('sha256').update(combined).digest('hex')
69
+ return { fingerprint: hash, activity: activity }
70
+ } catch (e) {
71
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
72
+ }
73
+ }
2
74
 
3
75
  export interface ScrollSelector { text?: string; resourceId?: string; contentDesc?: string; className?: string }
4
76
 
@@ -84,7 +156,6 @@ export async function scrollToElementShared(opts: {
84
156
  try {
85
157
  await swipe(x1, y1, x2, y2, duration, deviceId)
86
158
  } catch (e) {
87
- // Log swipe failures to aid debugging but don't fail the overall flow
88
159
  try { console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`) } catch {}
89
160
  }
90
161
 
@@ -1,7 +1,6 @@
1
- import { AndroidObserve } from "../../src/android/observe.js";
2
- import { AndroidInteract } from "../../src/android/interact.js";
3
- import { iOSObserve } from "../../src/ios/observe.js";
4
- import { iOSInteract } from "../../src/ios/interact.js";
1
+ import { AndroidObserve, iOSObserve } from "../../src/observe/index.js";
2
+ import { AndroidInteract } from "../../src/interact/index.js";
3
+ import { iOSInteract } from "../../src/interact/index.js";
5
4
  import fs from "fs/promises";
6
5
 
7
6
  const androidObserve = new AndroidObserve();
@@ -0,0 +1,32 @@
1
+ import { ToolsInteract } from '../../../src/interact/index.js'
2
+ import * as Observe from '../../../src/observe/index.js'
3
+
4
+ const original = (Observe as any).ToolsObserve.getScreenFingerprintHandler
5
+
6
+ async function runTests() {
7
+ console.log('Starting tests for wait_for_screen_change...')
8
+
9
+ // Test 1: Immediate change
10
+ let seq1: Array<string | null> = ['B','B']
11
+ ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq1.length ? seq1.shift() : null })
12
+ const start1 = Date.now()
13
+ const res1 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 2000, pollIntervalMs: 50 })
14
+ const elapsed1 = Date.now() - start1
15
+ console.log('Test 1: Immediate change ->', (res1 && (res1 as any).success === true && (res1 as any).newFingerprint === 'B') ? 'PASS' : 'FAIL', 'Elapsed:', elapsed1, 'ms')
16
+
17
+ // Test 2: Transient nulls then stable change
18
+ let seq2: Array<string | null> = [null, null, 'B', 'B']
19
+ ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq2.length ? seq2.shift() : 'B' })
20
+ const res2 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 3000, pollIntervalMs: 50 })
21
+ console.log('Test 2: Transient nulls ->', (res2 && (res2 as any).success === true && (res2 as any).newFingerprint === 'B') ? 'PASS' : 'FAIL')
22
+
23
+ // Test 3: Timeout
24
+ ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: 'A' })
25
+ const res3 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 300, pollIntervalMs: 50 })
26
+ console.log('Test 3: Timeout ->', (res3 && (res3 as any).success === false && (res3 as any).reason === 'timeout') ? 'PASS' : 'FAIL')
27
+
28
+ // Restore original
29
+ ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = original
30
+ }
31
+
32
+ runTests().catch(console.error)
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { AndroidManage } from '../../../dist/android/manage.js'
2
+ import { AndroidManage } from '../../../dist/utils/android/manage.js'
3
3
 
4
4
  async function main() {
5
5
  const [, , appPath, deviceId] = process.argv
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { iOSManage } from '../../../dist/ios/manage.js'
2
+ import { iOSManage } from '../../../dist/utils/ios/manage.js'
3
3
 
4
4
  async function main() {
5
5
  const [, , appPath, deviceId] = process.argv
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { ToolsManage } from '../../../dist/tools/manage.js'
2
+ import { ToolsManage } from '../../../dist/manage/index.js'
3
3
  import path from 'path'
4
4
 
5
5
  async function main() {
@@ -57,7 +57,7 @@ process.exit(0)
57
57
  // Prefer explicit XCODEBUILD_PATH to ensure deterministic behavior
58
58
  process.env.XCODEBUILD_PATH = xcodePath
59
59
 
60
- const { ToolsManage } = await import('../../../src/tools/manage.js')
60
+ const { ToolsManage } = await import('../../../src/manage/index.js')
61
61
 
62
62
  try {
63
63
  const ares = await ToolsManage.buildAppHandler({ platform: 'android', projectPath: androidProject })
@@ -93,7 +93,7 @@ process.exit(0)
93
93
  process.env.PATH = `${binDir}:${origPath}`
94
94
  process.env.XCRUN_PATH = simctlPath
95
95
 
96
- const { ToolsManage } = await import('../../../src/tools/manage.js')
96
+ const { ToolsManage } = await import('../../../src/manage/index.js')
97
97
 
98
98
  try {
99
99
  // Android build_and_install