mobile-debug-mcp 0.20.1 → 0.21.1

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 (33) hide show
  1. package/dist/interact/android.js +0 -27
  2. package/dist/interact/index.js +145 -124
  3. package/dist/interact/ios.js +0 -26
  4. package/dist/scripts/capture_ui_after_tap.mjs +43 -0
  5. package/dist/scripts/check_play_observe.mjs +18 -0
  6. package/dist/scripts/check_play_substring.mjs +38 -0
  7. package/dist/scripts/dump_ui_tree.mjs +20 -0
  8. package/dist/scripts/observe-test.mjs +32 -0
  9. package/dist/scripts/press_and_observe.mjs +90 -0
  10. package/dist/scripts/press_and_wait_ui.mjs +85 -0
  11. package/dist/scripts/test_generate_and_wait.mjs +123 -0
  12. package/dist/server.js +15 -25
  13. package/dist/system/gradle.js +4 -4
  14. package/dist/utils/android/utils.js +2 -2
  15. package/dist/utils/resolve-device.js +5 -0
  16. package/docs/CHANGELOG.md +7 -1
  17. package/docs/tools/interact.md +7 -27
  18. package/package.json +2 -2
  19. package/src/interact/android.ts +1 -32
  20. package/src/interact/index.ts +98 -78
  21. package/src/interact/ios.ts +1 -31
  22. package/src/server.ts +18 -25
  23. package/src/system/gradle.ts +4 -4
  24. package/src/utils/android/utils.ts +2 -2
  25. package/src/utils/resolve-device.ts +6 -0
  26. package/test/interact/device/run-real-test.ts +3 -19
  27. package/test/interact/unit/{observe_until.test.ts → wait_for_ui.test.ts} +6 -6
  28. package/test/observe/device/wait_for_element_real.ts +3 -80
  29. package/test/observe/unit/wait_for_element_mock.ts +2 -104
  30. package/test/observe/unit/wait_for_ui_edge_cases.test.ts +41 -0
  31. package/test/observe/unit/wait_for_ui_stability.test.ts +30 -0
  32. package/test/unit/index.ts +27 -15
  33. package/test/interact/device/observe_until_device.ts +0 -24
@@ -29,7 +29,6 @@ interface UiElement {
29
29
  _interactable?: boolean
30
30
  }
31
31
 
32
- const STABLE_IDLE_MS = 1000
33
32
 
34
33
  export class ToolsInteract {
35
34
 
@@ -40,12 +39,6 @@ export class ToolsInteract {
40
39
  return { interact: interact as any, resolved, platform: effectivePlatform }
41
40
  }
42
41
 
43
- static async waitForElementHandler({ platform, text, timeout, deviceId }: { platform: 'android' | 'ios', text: string, timeout?: number, deviceId?: string }) {
44
- const effectiveTimeout = timeout ?? 10000
45
- const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
46
- return await interact.waitForElement(text, effectiveTimeout, resolved.id)
47
- }
48
-
49
42
  static async tapHandler({ platform, x, y, deviceId }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string }) {
50
43
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
51
44
  return await interact.tap(x, y, resolved.id)
@@ -225,6 +218,11 @@ export class ToolsInteract {
225
218
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal }
226
219
  }
227
220
 
221
+ static async waitForUIHandler({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
222
+ // Backwards-compatible wrapper that delegates to the core waitForUICore implementation
223
+ return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
224
+ }
225
+
228
226
  static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
229
227
  const start = Date.now()
230
228
  let lastFingerprint: string | null = null
@@ -262,76 +260,94 @@ export class ToolsInteract {
262
260
  return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
263
261
  }
264
262
 
265
- static async observeUntilHandler({ type, query, timeoutMs = 5000, pollIntervalMs = 200, platform, deviceId }: { type: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
263
+ static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
266
264
  const start = Date.now()
267
265
  const deadline = start + (timeoutMs || 0)
268
266
  const q = (query === null || query === undefined) ? '' : String(query)
269
267
 
270
- // Baseline state
271
- let initialFingerprint: string | null = null
272
- try {
273
- const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
274
- initialFingerprint = fpRes?.fingerprint ?? null
275
- } catch (err) { console.error('observeUntil: error getting initial fingerprint', err); initialFingerprint = null }
268
+ // Clamp polling interval to 250-500ms for consistent behavior
269
+ const pollInterval = Math.max(250, Math.min(pollIntervalMs || 300, 500))
276
270
 
277
- // For logs, capture a baseline snapshot (count or last line) to avoid matching historical lines
271
+ // Baseline state (fetch in parallel but bound to short timeouts so observation starts promptly)
272
+ let initialFingerprint: string | null = null
278
273
  let baselineLastLine: string | null = null
279
274
  try {
280
- const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 })
281
- const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
282
- baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
275
+ const fpPromise = ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as Promise<ScreenFingerprintResponse | null>
276
+ const logsPromise = ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as Promise<any>
277
+ const withTimeout = (p: Promise<any>, ms: number) => Promise.race([p, new Promise(resolve => setTimeout(() => resolve(null), ms))])
278
+ const [fpRes, gl] = await Promise.all([withTimeout(fpPromise, 300), withTimeout(logsPromise, 500)])
279
+ if (fpRes && typeof fpRes === 'object') initialFingerprint = (fpRes as ScreenFingerprintResponse).fingerprint ?? null
280
+ if (gl) {
281
+ const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
282
+ baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
283
+ }
283
284
  } catch (err) {
284
- // non-fatal but surface warning to aid debugging
285
- try { console.warn('observeUntil: failed to get baseline logs (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
285
+ try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
286
286
  }
287
287
 
288
-
288
+ // Network-based waiting removed. Rely on UI and screen fingerprints for determinism.
289
289
  let lastChangeAt = Date.now()
290
290
  let prevFingerprint = initialFingerprint
291
291
 
292
292
  const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
293
293
 
294
+ // Optional initial observation delay requested by caller
295
+ if (typeof observationDelayMs === 'number' && observationDelayMs > 0) {
296
+ try { console.log(`waitForUI: delaying observation for ${observationDelayMs}ms`) } catch { }
297
+ await sleep(observationDelayMs)
298
+ }
299
+
294
300
  // Telemetry
295
301
  let pollCount = 0
296
- let timeToMatch: number | null = null
302
+ let matchedAt: number | null = null
303
+ let lastObservedState: boolean | null = null
304
+ let stableDuration = 0
297
305
  let matchSource: string | null = null
298
306
 
299
307
  while (Date.now() <= deadline) {
300
308
  pollCount++
301
- try {
309
+ const now = Date.now()
310
+ // Evaluate condition per type
302
311
  if (type === 'ui') {
303
- // fast findElement with short timeout to avoid blocking
304
312
  try {
305
- const found = await ToolsInteract.findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, timeoutMs || 500), platform, deviceId })
306
- if (found && (found as any).found) {
307
- timeToMatch = Date.now() - start
308
- // determine matchSource heuristics
309
- const el = (found as any).element || {}
310
- if (el && el.resourceId && String(el.resourceId).toLowerCase().includes(q.toLowerCase())) matchSource = 'ui-resourceId'
311
- else if (el && el.text && String(el.text).toLowerCase() === q.toLowerCase()) matchSource = 'ui-exact'
312
- else matchSource = 'ui-partial'
313
-
314
- return { success: true, type: 'ui', matched: true, details: `UI element matched '${q}'`, timestamp: Date.now(), element: (found as any).element, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
315
- }
316
- } catch (err) { console.error('observeUntil(ui) find error:', err) }
313
+ // Prefer using the public findElementHandler which tests can override. This avoids relying
314
+ // on resolveObserve/getUITree for unit tests which may not have devices available.
315
+ try {
316
+ const findRes = await (ToolsInteract as any).findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, pollInterval), platform, deviceId })
317
+ const isPresent = !!(findRes && (findRes as any).found)
318
+ const conditionTrue = (match === 'present') ? isPresent : !isPresent
319
+ if (conditionTrue) {
320
+ if (matchedAt === null) matchedAt = Date.now()
321
+ stableDuration = Date.now() - (matchedAt as number)
322
+ lastObservedState = true
323
+ if (stableDuration >= stability_ms) {
324
+ matchSource = 'ui-find'
325
+ const element = isPresent ? (findRes as any).element : null
326
+ const now2 = Date.now()
327
+ return { success: true, condition: match, query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: stableDuration, matchedElement: element, matchSource, timestamp: now2, type: 'ui', observed_state: lastObservedState ?? null }
328
+ }
329
+ } else {
330
+ matchedAt = null
331
+ stableDuration = 0
332
+ lastObservedState = false
333
+ }
334
+ } catch (err) { console.error('waitForUI(ui) find error:', err) }
335
+ } catch (err) { console.error('waitForUI(ui) outer error:', err) }
317
336
  } else if (type === 'log') {
318
337
  try {
319
- // Try reading from active stream first
338
+ // Logs: presence semantics only (match 'present'). Stability not applicable (immediate)
320
339
  const stream = await ToolsObserve.readLogStreamHandler({ platform, sessionId: 'default', limit: 200 }) as any
321
340
  const entries = (stream && Array.isArray(stream.entries)) ? stream.entries : []
322
341
  for (const ent of entries) {
323
342
  const msg = ent && (ent.message || ent.msg || ent) ? (ent.message || ent.msg || ent) : ''
324
343
  if (q && String(msg).includes(q)) {
325
- timeToMatch = Date.now() - start
326
- matchSource = 'log-stream'
327
- return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: msg, raw: ent }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
344
+ const now2 = Date.now()
345
+ return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: msg, raw: ent }, matchSource: 'log-stream', timestamp: now2, type: 'log', observed_state: true }
328
346
  }
329
347
  }
330
348
 
331
- // Fallback to snapshot logs
332
349
  const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
333
350
  const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
334
- // Only consider new lines after baselineLastLine when possible
335
351
  let startIndex = 0
336
352
  if (baselineLastLine) {
337
353
  const idx = logsArr.lastIndexOf(baselineLastLine)
@@ -340,35 +356,33 @@ export class ToolsInteract {
340
356
  for (let i = startIndex; i < logsArr.length; i++) {
341
357
  const line = logsArr[i]
342
358
  if (q && String(line).includes(q)) {
343
- timeToMatch = Date.now() - start
344
- matchSource = 'log-snapshot'
345
- return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: line }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
359
+ const now2 = Date.now()
360
+ return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true }
346
361
  }
347
362
  }
348
- } catch (err) { console.error('observeUntil(log) error:', err) }
363
+ } catch (err) { console.error('waitForUI(log) error:', err) }
349
364
  } else if (type === 'screen') {
350
365
  try {
351
366
  const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
352
367
  const fp = fpRes?.fingerprint ?? null
353
368
  if (fp !== null && fp !== undefined && fp !== initialFingerprint) {
354
- if (q) {
355
- // optionally validate query against new screen context
356
- try {
357
- const found = await ToolsInteract.findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, timeoutMs || 500), platform, deviceId })
358
- if (found && (found as any).found) {
359
- timeToMatch = Date.now() - start
360
- matchSource = 'screen-validated-ui'
361
- return { success: true, type: 'screen', matched: true, details: `Screen changed and query matched on new screen`, timestamp: Date.now(), newFingerprint: fp, element: (found as any).element, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
362
- }
363
- } catch (err) { console.error('observeUntil(screen) find error:', err) }
364
- // If query provided but not matched yet, continue polling until timeout
369
+ // when screen changed, require stability_ms where fingerprint remains the same
370
+ if (matchedAt === null) matchedAt = now
371
+ const confirmFp = (await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null)?.fingerprint ?? null
372
+ if (confirmFp === fp) {
373
+ stableDuration = Date.now() - (matchedAt as number)
374
+ lastObservedState = true
375
+ if (stableDuration >= stability_ms) {
376
+ const now2 = Date.now()
377
+ return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: stableDuration, newFingerprint: fp, matchSource: 'screen-fingerprint', timestamp: now2, type: 'screen', observed_state: lastObservedState ?? null }
378
+ }
365
379
  } else {
366
- timeToMatch = Date.now() - start
367
- matchSource = 'screen-fingerprint'
368
- return { success: true, type: 'screen', matched: true, details: 'Screen fingerprint changed', timestamp: Date.now(), newFingerprint: fp, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
380
+ matchedAt = null
381
+ stableDuration = 0
382
+ lastObservedState = false
369
383
  }
370
384
  }
371
- } catch (err) { console.error('observeUntil(screen) error:', err) }
385
+ } catch (err) { console.error('waitForUI(screen) error:', err) }
372
386
  } else if (type === 'idle') {
373
387
  try {
374
388
  const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
@@ -376,33 +390,39 @@ export class ToolsInteract {
376
390
  if (fp !== prevFingerprint) {
377
391
  prevFingerprint = fp
378
392
  lastChangeAt = Date.now()
393
+ matchedAt = null
394
+ stableDuration = 0
395
+ lastObservedState = false
379
396
  } else {
380
- if (Date.now() - lastChangeAt >= STABLE_IDLE_MS) {
381
- timeToMatch = Date.now() - start
382
- matchSource = 'idle-stable'
383
- return { success: true, type: 'idle', matched: true, details: `UI stable for ${STABLE_IDLE_MS}ms`, timestamp: Date.now(), fingerprint: fp, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
397
+ const idleMs = Date.now() - lastChangeAt
398
+ lastObservedState = true
399
+ if (idleMs >= stability_ms) {
400
+ const now2 = Date.now()
401
+ return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: idleMs, matchSource: 'idle-stable', timestamp: now2, type: 'idle', observed_state: lastObservedState ?? null }
384
402
  }
385
403
  }
386
- } catch (err) { console.error('observeUntil(idle) error:', err) }
404
+ } catch (err) { console.error('waitForUI(idle) error:', err) }
387
405
  }
388
- } catch (err) {
389
- console.error('observeUntil: unexpected error', err)
390
- }
391
406
 
392
407
  // Respect poll interval and avoid tight loop
393
- await sleep(pollIntervalMs || 200)
408
+ await sleep(pollInterval)
394
409
  }
395
410
 
396
- // On timeout, capture a failure snapshot to aid debugging (best-effort)
411
+ // On timeout, optionally capture a failure snapshot to aid debugging (best-effort)
397
412
  let snapshot: any = null
398
- try {
399
- snapshot = await ToolsObserve.captureDebugSnapshotHandler({ reason: `observe_until timeout for ${type}`, includeLogs: true, platform, deviceId })
400
- } catch (err) {
401
- snapshot = { error: err instanceof Error ? err.message : String(err) }
413
+ if (includeSnapshotOnFailure) {
414
+ try {
415
+ // Use dynamic import to avoid circular-initialization issues where the ToolsObserve
416
+ // binding captured earlier may not reflect test-time overrides. Importing at call
417
+ // time ensures the latest exported ToolsObserve object is used.
418
+ const Obs = await import('../observe/index.js')
419
+ snapshot = await (Obs as any).ToolsObserve.captureDebugSnapshotHandler({ reason: `wait_for_ui timeout for ${type}`, includeLogs: true, platform, deviceId })
420
+ } catch (err) {
421
+ snapshot = { error: err instanceof Error ? err.message : String(err) }
422
+ }
402
423
  }
403
424
 
404
425
  const elapsed = Date.now() - start
405
- return { success: false, error: 'Timeout waiting for condition', type, timeoutMs, telemetry: { pollCount, elapsedMs: elapsed, matchSource: null }, snapshot }
406
- }
407
-
426
+ return { success: false, condition: match, query: q, poll_count: pollCount, duration_ms: elapsed, stable_duration_ms: stableDuration, error: 'Timeout waiting for condition', snapshot, observed_state: lastObservedState ?? null }
427
+ }
408
428
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process"
2
- import { WaitForElementResponse, TapResponse, SwipeResponse } from "../types.js"
2
+ import { TapResponse, SwipeResponse } from "../types.js"
3
3
  import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
4
4
  import { iOSObserve } from "../observe/index.js"
5
5
  import { scrollToElementShared } from "../utils/ui/index.js"
@@ -7,36 +7,6 @@ import { scrollToElementShared } from "../utils/ui/index.js"
7
7
  export class iOSInteract {
8
8
  private observe = new iOSObserve();
9
9
 
10
- async waitForElement(text: string, timeout: number, deviceId: string = "booted"): Promise<WaitForElementResponse> {
11
- const device = await getIOSDeviceMetadata(deviceId);
12
- const startTime = Date.now();
13
-
14
- while (Date.now() - startTime < timeout) {
15
- try {
16
- const tree = await this.observe.getUITree(deviceId);
17
-
18
- if (tree.error) {
19
- return { device, found: false, error: tree.error };
20
- }
21
-
22
- const element = tree.elements.find(e => e.text === text);
23
- if (element) {
24
- return { device, found: true, element };
25
- }
26
- } catch (e) {
27
- // Ignore errors during polling and retry
28
- console.error("Error polling UI tree:", e);
29
- }
30
-
31
- const elapsed = Date.now() - startTime;
32
- const remaining = timeout - elapsed;
33
- if (remaining <= 0) break;
34
-
35
- await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
36
- }
37
- return { device, found: false };
38
- }
39
-
40
10
  async tap(x: number, y: number, deviceId: string = "booted"): Promise<TapResponse> {
41
11
  const device = await getIOSDeviceMetadata(deviceId)
42
12
 
package/src/server.ts CHANGED
@@ -340,33 +340,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
340
340
  }
341
341
  },
342
342
  {
343
- name: "wait_for_element",
344
- description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
343
+ name: "wait_for_ui",
344
+ description: "Wait for a UI/log/screen/idle condition with a stability window before returning success.",
345
345
  inputSchema: {
346
346
  type: "object",
347
347
  properties: {
348
- platform: {
349
- type: "string",
350
- enum: ["android", "ios"],
351
- description: "Platform to check"
352
- },
353
- text: {
354
- type: "string",
355
- description: "Text content of the element to wait for"
356
- },
357
- timeout: {
358
- type: "number",
359
- description: "Max wait time in ms (default 10000)",
360
- default: 10000
361
- },
362
- deviceId: {
363
- type: "string",
364
- description: "Device Serial/UDID. Defaults to connected/booted device."
365
- }
366
- },
367
- required: ["platform", "text"]
348
+ type: { type: "string", enum: ["ui","log","screen","idle"], description: "Condition type to observe", default: "ui" },
349
+ query: { type: "string", description: "Optional query string for ui/log/screen types" },
350
+ timeoutMs: { type: "number", description: "Timeout in ms to wait for condition (default 30000)", default: 30000 },
351
+ pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300, clamped to 250-500)", default: 300 },
352
+ match: { type: "string", enum: ["present","absent"], description: "Match mode for UI checks: 'present' or 'absent' (default 'present')", default: "present" },
353
+ stability_ms: { type: "number", description: "Stability window in ms that the condition must hold before returning success (default 700)", default: 700 },
354
+ includeSnapshotOnFailure: { type: "boolean", description: "Whether to include a debug snapshot on timeout (default true)", default: true },
355
+ platform: { type: "string", enum: ["android","ios"], description: "Optional platform override" },
356
+ deviceId: { type: "string", description: "Optional device serial/udid" }
357
+ }
368
358
  }
369
359
  },
360
+
361
+
370
362
  {
371
363
  name: "find_element",
372
364
  description: "Find a UI element by semantic query (text, content-desc, resource-id, class). Returns best match.",
@@ -674,9 +666,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
674
666
  return wrapResponse(res)
675
667
  }
676
668
 
677
- if (name === "wait_for_element") {
678
- const { platform, text, timeout, deviceId } = (args || {}) as any
679
- const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId })
669
+
670
+ if (name === "wait_for_ui") {
671
+ const { type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId } = (args || {}) as any
672
+ const res = await ToolsInteract.waitForUIHandler({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
680
673
  return wrapResponse(res)
681
674
  }
682
675
 
@@ -17,7 +17,7 @@ function readPropertiesFile(p: string): Record<string,string> {
17
17
  out[k] = v
18
18
  }
19
19
  return out
20
- } catch (e: unknown) {
20
+ } catch {
21
21
  return {}
22
22
  }
23
23
  }
@@ -30,7 +30,7 @@ function javaBinExists(p?: string): boolean {
30
30
  const alt = path.join(p, 'Contents', 'Home', 'bin', 'java')
31
31
  if (existsSync(alt)) return true
32
32
  return false
33
- } catch (e: unknown) { return false }
33
+ } catch { return false }
34
34
  }
35
35
 
36
36
  export async function checkGradle(): Promise<{ gradleJavaHome?: string; gradleValid: boolean; filesChecked: string[]; issues: string[]; suggestedFixes?: string[] }> {
@@ -62,7 +62,7 @@ export async function checkGradle(): Promise<{ gradleJavaHome?: string; gradleVa
62
62
  suggestedFixes.push(`Edit ${userProps} to remove or correct org.gradle.java.home`)
63
63
  }
64
64
  }
65
- } catch (e: unknown) { /* ignore */ }
65
+ } catch { }
66
66
 
67
67
  // 3) system gradle.properties
68
68
  const systemProps = '/etc/gradle/gradle.properties'
@@ -77,7 +77,7 @@ export async function checkGradle(): Promise<{ gradleJavaHome?: string; gradleVa
77
77
  suggestedFixes.push(`Edit ${systemProps} to remove or correct org.gradle.java.home`)
78
78
  }
79
79
  }
80
- } catch (e: unknown) { /* ignore */ }
80
+ } catch { }
81
81
 
82
82
  // 4) GRADLE_HOME fallback
83
83
  if (!gradleJavaHome && process.env.GRADLE_HOME) {
@@ -78,7 +78,7 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
78
78
  let gradleCheck
79
79
  try {
80
80
  gradleCheck = await checkGradle()
81
- } catch (e: unknown) {
81
+ } catch {
82
82
  gradleCheck = { gradleJavaHome: undefined, gradleValid: false, filesChecked: [], issues: [] }
83
83
  }
84
84
 
@@ -117,7 +117,7 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
117
117
  } else {
118
118
  // Invalid gradle java home detected: avoid passing it to Gradle and remove from spawn env
119
119
  console.debug(`[prepareGradle] Invalid org.gradle.java.home detected (${gradleCheck.gradleJavaHome}); removing from spawn env to avoid Gradle error.`)
120
- try { delete env.GRADLE_JAVA_HOME } catch (e: unknown) { }
120
+ try { delete env.GRADLE_JAVA_HOME } catch { }
121
121
  }
122
122
  }
123
123
 
@@ -32,6 +32,12 @@ export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceI
32
32
  const { platform, appId, prefer, deviceId } = opts
33
33
  const devices = await listDevices(platform, appId)
34
34
 
35
+ // During unit tests (no adb/xcrun available), provide a lightweight mock device so
36
+ // the observe/interact unit tests can run without real devices.
37
+ if ((!devices || devices.length === 0) && (process.env.NODE_ENV === 'test' || process.env.MCP_TEST_MOCK_DEVICES === '1')) {
38
+ return { id: 'mock', platform: platform || 'android', osVersion: '12', model: 'Pixel', simulator: true } as DeviceInfo
39
+ }
40
+
35
41
  if (deviceId) {
36
42
  const found = devices.find(d => d.id === deviceId)
37
43
  if (!found) throw new Error(`Device '${deviceId}' not found for platform ${platform}`)
@@ -1,19 +1,3 @@
1
- import { spawn } from 'child_process';
2
- import path from 'path';
3
- import { fileURLToPath } from 'url';
4
- const __filename = fileURLToPath(import.meta.url);
5
- const __dirname = path.dirname(__filename);
6
- const ADB_PATH = process.env.ADB_PATH || process.env.ADB || 'adb';
7
- const TEST_FILE = path.join(__dirname, 'wait_for_element_real.ts');
8
-
9
- const childEnv = { ...process.env, ADB_PATH };
10
- const runner = process.env.RUNNER || 'npx';
11
- const runnerArgs = ['tsx', TEST_FILE];
12
-
13
- const child = spawn(runner, runnerArgs, {
14
- env: childEnv,
15
- stdio: 'inherit'
16
- });
17
- child.on('exit', (code) => {
18
- process.exit(code || 0);
19
- });
1
+ // wait_for_element device runner removed
2
+ console.log('wait_for_element device runner removed');
3
+ process.exit(0);
@@ -2,7 +2,7 @@ import { ToolsInteract } from '../../../src/interact/index.js'
2
2
  import * as Observe from '../../../src/observe/index.js'
3
3
 
4
4
  async function runTests() {
5
- console.log('Starting observe_until unit tests...')
5
+ console.log('Starting wait_for_ui unit tests...')
6
6
 
7
7
  const origFind = (ToolsInteract as any).findElementHandler
8
8
  const origReadLog = (Observe as any).ToolsObserve.readLogStreamHandler
@@ -17,7 +17,7 @@ async function runTests() {
17
17
  ;(Observe as any).ToolsObserve.captureDebugSnapshotHandler = async ({ reason }: any) => ({ reason, fingerprint: 'snap-123', ui_tree: null, logs: [] })
18
18
  // make findElement always fail
19
19
  (ToolsInteract as any).findElementHandler = async () => ({ found: false })
20
- const resTimeout = await ToolsInteract.observeUntilHandler({ type: 'ui', query: 'WillNeverExist', timeoutMs: 500, pollIntervalMs: 100, platform: 'android' })
20
+ const resTimeout = await ToolsInteract.waitForUIHandler({ type: 'ui', query: 'WillNeverExist', timeoutMs: 500, pollIntervalMs: 100, platform: 'android' })
21
21
  const okTimeout = resTimeout && !(resTimeout as any).success && (resTimeout as any).snapshot && (resTimeout as any).snapshot.fingerprint === 'snap-123' && (resTimeout as any).telemetry && (resTimeout as any).telemetry.pollCount > 0
22
22
  console.log('Timeout Snapshot Test:', okTimeout ? 'PASS' : 'FAIL', JSON.stringify((resTimeout as any).telemetry || {}, null, 2))
23
23
  ;(Observe as any).ToolsObserve.captureDebugSnapshotHandler = origCapture
@@ -31,7 +31,7 @@ async function runTests() {
31
31
  return { found: false }
32
32
  }
33
33
 
34
- const resUi = await ToolsInteract.observeUntilHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
34
+ const resUi = await ToolsInteract.waitForUIHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
35
35
  const okUi = resUi && (resUi as any).success && (resUi as any).telemetry && (resUi as any).telemetry.pollCount > 0 && (resUi as any).telemetry.timeToMatch >= 0
36
36
  console.log('UI Test:', okUi ? 'PASS' : 'FAIL', JSON.stringify((resUi as any).telemetry || {}, null, 2))
37
37
 
@@ -44,21 +44,21 @@ async function runTests() {
44
44
  return { device: {}, logs: ['INFO start', 'ERROR Exception occurred', 'Server: Boom'] }
45
45
  }
46
46
 
47
- const resLog = await ToolsInteract.observeUntilHandler({ type: 'log', query: 'Server', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
47
+ const resLog = await ToolsInteract.waitForUIHandler({ type: 'log', query: 'Server', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
48
48
  const okLog = resLog && (resLog as any).success && (resLog as any).telemetry && (resLog as any).telemetry.pollCount > 0 && (resLog as any).telemetry.matchSource === 'log-snapshot'
49
49
  console.log('Log Test:', okLog ? 'PASS' : 'FAIL', JSON.stringify((resLog as any).telemetry || {}, null, 2))
50
50
 
51
51
  // Screen condition: fingerprint changes after a few polls
52
52
  let seq = ['A', 'A', 'B']
53
53
  ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq.length ? seq.shift() : null })
54
- const resScreen = await ToolsInteract.observeUntilHandler({ type: 'screen', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
54
+ const resScreen = await ToolsInteract.waitForUIHandler({ type: 'screen', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
55
55
  const okScreen = resScreen && (resScreen as any).success && (resScreen as any).telemetry && (resScreen as any).telemetry.matchSource === 'screen-fingerprint'
56
56
  console.log('Screen Test:', okScreen ? 'PASS' : 'FAIL', JSON.stringify((resScreen as any).telemetry || {}, null, 2))
57
57
 
58
58
  // Idle condition: stable fingerprints observed
59
59
  let idleSeq = ['X', 'X', 'X']
60
60
  ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: idleSeq.length ? idleSeq.shift() : 'X' })
61
- const resIdle = await ToolsInteract.observeUntilHandler({ type: 'idle', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
61
+ const resIdle = await ToolsInteract.waitForUIHandler({ type: 'idle', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
62
62
  const okIdle = resIdle && (resIdle as any).success && (resIdle as any).telemetry && (resIdle as any).telemetry.matchSource === 'idle-stable'
63
63
  console.log('Idle Test:', okIdle ? 'PASS' : 'FAIL', JSON.stringify((resIdle as any).telemetry || {}, null, 2))
64
64
 
@@ -1,80 +1,3 @@
1
- import { AndroidInteract } from "../../src/interact/index.js";
2
- import { AndroidObserve } from "../../src/observe/index.js";
3
-
4
- // Usage: npx tsx test/wait_for_element_real.ts <deviceId> <appId>
5
- const args = process.argv.slice(2);
6
- const DEVICE_ID = args[0] || process.env.DEVICE_ID;
7
- const APP_ID = args[1] || process.env.APP_ID;
8
-
9
- if (!DEVICE_ID || !APP_ID) {
10
- console.error("Usage: npx tsx test/wait_for_element_real.ts <deviceId> <appId> or set DEVICE_ID and APP_ID env vars");
11
- process.exit(1);
12
- }
13
-
14
- async function runRealTest() {
15
- console.log(`Connecting to device ${DEVICE_ID}...`);
16
- const interact = new AndroidInteract();
17
- const observe = new AndroidObserve();
18
- try {
19
- console.log(`\nStarting app ${APP_ID}...`);
20
- await interact.startApp(APP_ID, DEVICE_ID);
21
- console.log("Waiting 3s for app to render...");
22
- await new Promise(r => setTimeout(r, 3000));
23
-
24
- console.log("\nFetching UI Tree to find a target text...");
25
- const tree = await observe.getUITree(DEVICE_ID);
26
- if (tree.error) {
27
- console.error("Failed to get UI Tree:", tree.error);
28
- return;
29
- }
30
-
31
- const targetElement = tree.elements.find(e => e.text && e.text.length > 0 && e.visible);
32
- if (!targetElement || !targetElement.text) {
33
- console.warn("No visible text elements found on screen to test with.");
34
- console.log("Elements found:", tree.elements.length);
35
- return;
36
- }
37
-
38
- const targetText = targetElement.text;
39
- console.log(`Found target element: "${targetText}"`);
40
-
41
- console.log(`\nTest 1: Waiting for existing element "${targetText}" (should succeed)...`);
42
- const start1 = Date.now();
43
- const result1 = await interact.waitForElement(targetText, 5000, DEVICE_ID);
44
- const elapsed1 = Date.now() - start1;
45
- console.log(`Result: ${result1.found ? "PASS" : "FAIL"}`);
46
- console.log(`Found Element: ${result1.element?.text}`);
47
- console.log(`Time taken: ${elapsed1}ms`);
48
-
49
- const missingText = "THIS_TEXT_SHOULD_NOT_EXIST_XYZ_123";
50
- console.log(`\nTest 2: Waiting for missing element "${missingText}" (should timeout)...`);
51
- const start2 = Date.now();
52
- const result2 = await interact.waitForElement(missingText, 2000, DEVICE_ID);
53
- const elapsed2 = Date.now() - start2;
54
- console.log(`Result: ${!result2.found ? "PASS" : "FAIL"}`);
55
- console.log(`Found: ${result2.found}`);
56
- console.log(`Time taken: ${elapsed2}ms (expected ~2000ms)`);
57
-
58
- console.log(`\nTest 3: Found after polling`);
59
- let calls = 0;
60
- AndroidObserve.prototype.getUITree = async function() {
61
- calls++;
62
- if (calls < 3) {
63
- return { device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true }, screen: "", resolution: { width: 1080, height: 1920 }, elements: [] };
64
- }
65
- return { device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true }, screen: "", resolution: { width: 1080, height: 1920 }, elements: [{ text: "Target", type: "Button", contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [0,0,100,100], resourceId: null }] };
66
- } as any;
67
-
68
- const start3 = Date.now();
69
- const result3 = await interact.waitForElement("Target", 2000, DEVICE_ID);
70
- const elapsed3 = Date.now() - start3;
71
- console.log(`Result: ${result3.found ? "PASS" : "FAIL"}`);
72
- console.log(`Calls: ${calls} ${calls === 3 ? "PASS" : "FAIL"}`);
73
- console.log(`Elapsed time (should be >= 1000ms): ${elapsed3} ${elapsed3 >= 1000 ? "PASS" : "FAIL"}`);
74
-
75
- } catch {
76
- console.error("Test failed with error:", error);
77
- }
78
- }
79
-
80
- runRealTest();
1
+ // wait_for_element device runner removed
2
+ console.log('wait_for_element device test removed');
3
+ process.exit(0);