mobile-debug-mcp 0.31.0 → 0.32.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 (109) hide show
  1. package/README.md +1 -0
  2. package/dist/interact/android.js +13 -8
  3. package/dist/interact/index.js +33 -22
  4. package/dist/interact/ios.js +5 -2
  5. package/dist/manage/android.js +20 -10
  6. package/dist/manage/ios.js +19 -10
  7. package/dist/observe/android-tree-bridge.js +192 -0
  8. package/dist/observe/android.js +53 -14
  9. package/dist/observe/index.js +22 -5
  10. package/dist/observe/ios.js +50 -29
  11. package/dist/observe/performance-metrics.js +124 -0
  12. package/dist/observe/snapshot-metadata.js +94 -15
  13. package/dist/server/common.js +14 -7
  14. package/dist/server/journey-schema.js +60 -0
  15. package/dist/server/tool-definitions.js +59 -6
  16. package/dist/server/tool-handlers.js +381 -56
  17. package/dist/server-core.js +1 -1
  18. package/dist/utils/exec.js +18 -5
  19. package/dist/utils/ios/utils.js +5 -2
  20. package/dist/utils/operation-budget.js +25 -0
  21. package/dist/utils/resolve-device.js +12 -1
  22. package/dist/utils/timing.js +19 -0
  23. package/dist/utils/ui/index.js +2 -0
  24. package/docs/CHANGELOG.md +6 -0
  25. package/docs/ROADMAP.md +9 -0
  26. package/docs/benchmarks/interaction-latency/README.md +26 -0
  27. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  28. package/docs/benchmarks/interaction-latency/final.md +17 -0
  29. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  30. package/docs/benchmarks/ui-tree-performance/README.md +51 -0
  31. package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
  32. package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
  33. package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
  34. package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
  35. package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
  36. package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
  37. package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
  38. package/docs/specs/018-interaction-latency-and-batched-journeys/spec.md +301 -0
  39. package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
  40. package/docs/tools/interact.md +30 -0
  41. package/docs/tools/observe.md +1 -0
  42. package/package.json +1 -1
  43. package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
  44. package/src/interact/android.ts +13 -8
  45. package/src/interact/index.ts +32 -23
  46. package/src/interact/ios.ts +5 -2
  47. package/src/manage/android.ts +19 -10
  48. package/src/manage/ios.ts +18 -10
  49. package/src/observe/android-tree-bridge.ts +170 -0
  50. package/src/observe/android.ts +48 -17
  51. package/src/observe/index.ts +21 -5
  52. package/src/observe/ios.ts +49 -36
  53. package/src/observe/performance-metrics.ts +125 -0
  54. package/src/observe/snapshot-metadata.ts +94 -15
  55. package/src/server/common.ts +13 -6
  56. package/src/server/journey-schema.ts +63 -0
  57. package/src/server/tool-definitions.ts +60 -7
  58. package/src/server/tool-handlers.ts +349 -54
  59. package/src/server-core.ts +1 -1
  60. package/src/types.ts +31 -2
  61. package/src/utils/exec.ts +13 -5
  62. package/src/utils/ios/utils.ts +5 -2
  63. package/src/utils/operation-budget.ts +22 -0
  64. package/src/utils/resolve-device.ts +13 -1
  65. package/src/utils/timing.ts +15 -0
  66. package/src/utils/ui/index.ts +2 -0
  67. package/test/device/automated/latency/benchmark.ts +136 -0
  68. package/test/device/automated/latency/report.ts +6 -0
  69. package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
  70. package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
  71. package/test/fixtures/latency-journey-app/AndroidManifest.xml +7 -0
  72. package/test/fixtures/latency-journey-app/README.md +28 -0
  73. package/test/fixtures/latency-journey-app/build.ts +26 -0
  74. package/test/fixtures/latency-journey-app/src/MainActivity.java +104 -0
  75. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
  76. package/test/fixtures/ui-tree-bridge/build.ts +34 -0
  77. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
  78. package/test/unit/manage/restart_budget.test.ts +37 -0
  79. package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
  80. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
  81. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  82. package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
  83. package/test/unit/observe/performance_metrics.test.ts +40 -0
  84. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  85. package/test/unit/observe/state_delta.test.ts +15 -0
  86. package/test/unit/server/benchmark_report.test.ts +4 -0
  87. package/test/unit/server/contract.test.ts +1 -1
  88. package/test/unit/server/interaction_modes.test.ts +60 -0
  89. package/test/unit/server/operation_budget.test.ts +45 -0
  90. package/test/unit/server/response_shapes.test.ts +3 -3
  91. package/test/unit/server/run_journey.test.ts +120 -0
  92. package/test/unit/server/tap_selector.test.ts +68 -0
  93. /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
  94. /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
  95. /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
  96. /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
  97. /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
  98. /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
  99. /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
  100. /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
  101. /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
  102. /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
  103. /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
  104. /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
  105. /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
  106. /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
  107. /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
  108. /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
  109. /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
@@ -1,3 +1,4 @@
1
+ import { markDispatchStarted } from '../utils/operation-budget.js'
1
2
  import { TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
2
3
  import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
3
4
  import { AndroidObserve } from "../observe/index.js"
@@ -7,31 +8,33 @@ import { scrollToElementShared } from "../utils/ui/index.js"
7
8
  export class AndroidInteract {
8
9
  private observe = new AndroidObserve();
9
10
 
10
- async tap(x: number, y: number, deviceId?: string): Promise<TapResponse> {
11
+ async tap(x: number, y: number, deviceId?: string, timeoutMs?: number): Promise<TapResponse> {
11
12
  const metadata = await getAndroidDeviceMetadata("", deviceId)
12
13
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
13
14
 
14
15
  try {
15
- await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId)
16
+ markDispatchStarted()
17
+ await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs })
16
18
  return { device: deviceInfo, success: true, x, y }
17
19
  } catch (e) {
18
20
  return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) }
19
21
  }
20
22
  }
21
23
 
22
- async swipe(x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string): Promise<SwipeResponse> {
24
+ async swipe(x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string, timeoutMs?: number): Promise<SwipeResponse> {
23
25
  const metadata = await getAndroidDeviceMetadata("", deviceId)
24
26
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
25
27
 
26
28
  try {
27
- await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId)
29
+ markDispatchStarted()
30
+ await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs })
28
31
  return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration }
29
32
  } catch (e) {
30
33
  return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) }
31
34
  }
32
35
  }
33
36
 
34
- async typeText(text: string, deviceId?: string): Promise<TypeTextResponse> {
37
+ async typeText(text: string, deviceId?: string, timeoutMs?: number): Promise<TypeTextResponse> {
35
38
  const metadata = await getAndroidDeviceMetadata("", deviceId)
36
39
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
37
40
 
@@ -39,19 +42,21 @@ export class AndroidInteract {
39
42
  // Encode spaces as %s to ensure proper input handling by adb shell input text
40
43
  const encodedText = text.replace(/\s/g, '%s')
41
44
  // Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
42
- await execAdb(['shell', 'input', 'text', encodedText], deviceId)
45
+ markDispatchStarted()
46
+ await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs })
43
47
  return { device: deviceInfo, success: true, text }
44
48
  } catch (e) {
45
49
  return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) }
46
50
  }
47
51
  }
48
52
 
49
- async pressBack(deviceId?: string): Promise<PressBackResponse> {
53
+ async pressBack(deviceId?: string, timeoutMs?: number): Promise<PressBackResponse> {
50
54
  const metadata = await getAndroidDeviceMetadata("", deviceId)
51
55
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
52
56
 
53
57
  try {
54
- await execAdb(['shell', 'input', 'keyevent', '4'], deviceId)
58
+ markDispatchStarted()
59
+ await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs })
55
60
  return { device: deviceInfo, success: true }
56
61
  } catch (e) {
57
62
  return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) }
@@ -1,3 +1,4 @@
1
+ import { remainingBudget } from '../utils/operation-budget.js'
1
2
  import { createHash } from 'crypto'
2
3
  import { AndroidInteract } from './android.js';
3
4
  import { iOSInteract } from './ios.js';
@@ -1226,22 +1227,22 @@ export class ToolsInteract {
1226
1227
  return { interact: interact as any, resolved, platform: effectivePlatform }
1227
1228
  }
1228
1229
 
1229
- static async tapHandler({ platform, x, y, deviceId }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string }) {
1230
+ static async tapHandler({ platform, x, y, deviceId, timeoutMs }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string, timeoutMs?: number }) {
1230
1231
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
1231
- return await interact.tap(x, y, resolved.id)
1232
+ return await interact.tap(x, y, resolved.id, timeoutMs)
1232
1233
  }
1233
1234
 
1234
- static async tapElementHandler({ elementId }: { elementId: string }): Promise<TapElementResponse> {
1235
+ static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }: { elementId: string, verificationMode?: 'none' | 'light' | 'full', timeoutMs?: number, platform?: 'android' | 'ios', deviceId?: string, freshTree?: any }): Promise<TapElementResponse> {
1235
1236
  const actionType = 'tap_element'
1236
1237
  const selector = { elementId }
1237
1238
  const resolved = ToolsInteract._resolvedUiElements.get(elementId)
1238
- if (!resolved) {
1239
+ if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) {
1239
1240
  return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null)
1240
1241
  }
1241
1242
 
1242
- const fingerprintBefore = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
1243
+ const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
1243
1244
 
1244
- const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
1245
+ const tree = freshTree ?? await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
1245
1246
  const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform
1246
1247
  const treeDeviceId = tree?.device?.id || resolved.deviceId
1247
1248
  const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
@@ -1272,14 +1273,14 @@ export class ToolsInteract {
1272
1273
 
1273
1274
  const x = Math.floor((bounds[0] + bounds[2]) / 2)
1274
1275
  const y = Math.floor((bounds[1] + bounds[3]) / 2)
1275
- const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId })
1276
+ const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId, timeoutMs })
1276
1277
 
1277
1278
  if (!tapResult.success) {
1278
- const fingerprintAfterFailure = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
1279
- return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure)
1279
+ const fingerprintAfterFailure = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
1280
+ return { ...ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure), error: tapResult.error }
1280
1281
  }
1281
1282
 
1282
- const fingerprintAfter = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
1283
+ const fingerprintAfter = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
1283
1284
  return buildActionExecutionResult({
1284
1285
  actionType,
1285
1286
  device: tree?.device,
@@ -2204,19 +2205,19 @@ export class ToolsInteract {
2204
2205
  return buildFailure('CONTROL_CONVERGENCE_FAILED', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter)
2205
2206
  }
2206
2207
 
2207
- static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }) {
2208
+ static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId, timeoutMs }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string, timeoutMs?: number }) {
2208
2209
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
2209
- return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
2210
+ return await interact.swipe(x1, y1, x2, y2, duration, resolved.id, timeoutMs)
2210
2211
  }
2211
2212
 
2212
- static async typeTextHandler({ text, deviceId }: { text: string, deviceId?: string }) {
2213
+ static async typeTextHandler({ text, deviceId, timeoutMs }: { text: string, deviceId?: string, timeoutMs?: number }) {
2213
2214
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
2214
- return await new AndroidInteract().typeText(text, resolved.id)
2215
+ return await new AndroidInteract().typeText(text, resolved.id, timeoutMs)
2215
2216
  }
2216
2217
 
2217
- static async pressBackHandler({ deviceId }: { deviceId?: string }) {
2218
+ static async pressBackHandler({ deviceId, timeoutMs }: { deviceId?: string, timeoutMs?: number }) {
2218
2219
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
2219
- return await new AndroidInteract().pressBack(resolved.id)
2220
+ return await new AndroidInteract().pressBack(resolved.id, timeoutMs)
2220
2221
  }
2221
2222
 
2222
2223
  static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }: { platform: 'android' | 'ios', selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction?: 'down' | 'up', maxScrolls?: number, scrollAmount?: number, deviceId?: string }) {
@@ -2490,7 +2491,7 @@ export class ToolsInteract {
2490
2491
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution }
2491
2492
  }
2492
2493
 
2493
- static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
2494
+ static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string, singleObservation?: boolean, singleAttempt?: boolean, rejectAmbiguous?: boolean }) {
2494
2495
  const overallStart = Date.now()
2495
2496
  const requestedIndex = typeof match?.index === 'number' ? match.index : null
2496
2497
  const requested = {
@@ -2538,7 +2539,7 @@ export class ToolsInteract {
2538
2539
  let lastConditionSatisfied = false
2539
2540
  let matchedAt: number | null = null
2540
2541
  let stableMatchCount = 0
2541
- const stableObservationCount = 2
2542
+ const stableObservationCount = singleObservation ? 1 : 2
2542
2543
  const snapshotStaleThresholdMs = 500
2543
2544
 
2544
2545
  // Precompute normalized selector values and helpers (constant across polls)
@@ -2554,7 +2555,8 @@ export class ToolsInteract {
2554
2555
  const attemptStart = Date.now()
2555
2556
  const deadline = attemptStart + (timeout_ms || 0)
2556
2557
 
2557
- while (Date.now() <= deadline) {
2558
+ while (Date.now() <= deadline || (singleAttempt && totalPollCount === 0)) {
2559
+ remainingBudget()
2558
2560
  totalPollCount++
2559
2561
  try {
2560
2562
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
@@ -2601,6 +2603,7 @@ export class ToolsInteract {
2601
2603
 
2602
2604
  // Evaluate condition
2603
2605
  const matchedCount = matches.length
2606
+ if (rejectAmbiguous && matchedCount > 1 && match?.index === undefined) return { status: 'timeout', error: { code: 'AMBIGUOUS_TARGET' } }
2604
2607
  const pickIndex = (typeof match?.index === 'number') ? match!.index as number : undefined
2605
2608
  let chosen: { el: any, idx: number } | null = null
2606
2609
  if (matches.length > 0) {
@@ -2659,6 +2662,7 @@ export class ToolsInteract {
2659
2662
 
2660
2663
  return {
2661
2664
  status: 'success',
2665
+ ...(singleObservation ? { _tree: tree } : {}),
2662
2666
  matched: matchedCount,
2663
2667
  element: outEl,
2664
2668
  metrics: { latency_ms, poll_count: totalPollCount, attempts },
@@ -2677,10 +2681,12 @@ export class ToolsInteract {
2677
2681
  }
2678
2682
 
2679
2683
  } catch (e) {
2684
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
2680
2685
  // Non-fatal per-poll error; record and continue
2681
2686
  console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
2682
2687
  }
2683
2688
 
2689
+ if (singleAttempt) break
2684
2690
  // Sleep until next poll
2685
2691
  await new Promise(r => setTimeout(r, effectivePoll || 50))
2686
2692
  }
@@ -2712,6 +2718,7 @@ export class ToolsInteract {
2712
2718
  }
2713
2719
 
2714
2720
  } catch (err) {
2721
+ if (err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
2715
2722
  const elapsed = Date.now() - overallStart
2716
2723
  return {
2717
2724
  status: 'timeout',
@@ -3139,7 +3146,8 @@ export class ToolsInteract {
3139
3146
  stabilization_window_ms = 1000,
3140
3147
  stable_observation_count = 2,
3141
3148
  snapshot_stale_threshold_ms = 500,
3142
- poll_interval_ms = 150
3149
+ poll_interval_ms = 150,
3150
+ exact_budget = false
3143
3151
  }: {
3144
3152
  selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean },
3145
3153
  element_id?: string,
@@ -3150,16 +3158,17 @@ export class ToolsInteract {
3150
3158
  stabilization_window_ms?: number,
3151
3159
  stable_observation_count?: number,
3152
3160
  snapshot_stale_threshold_ms?: number,
3153
- poll_interval_ms?: number
3161
+ poll_interval_ms?: number,
3162
+ exact_budget?: boolean
3154
3163
  }): Promise<ExpectStateResponse> {
3155
3164
  const compareBoolean = (value: unknown) => typeof value === 'boolean' ? value : null
3156
3165
  const compareString = (value: unknown) => typeof value === 'string' ? value : null
3157
3166
  const compareNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
3158
3167
  const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
3159
3168
  const start = Date.now()
3160
- const deadline = start + Math.max(500, stabilization_window_ms)
3169
+ const deadline = start + (exact_budget ? stabilization_window_ms : Math.max(500, stabilization_window_ms))
3161
3170
  const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2))
3162
- const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200))
3171
+ const pollDelay = exact_budget ? Math.max(50, Math.min(poll_interval_ms || 150, 1000)) : Math.max(100, Math.min(poll_interval_ms || 150, 200))
3163
3172
  const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800))
3164
3173
 
3165
3174
  let attempts = 0
@@ -1,3 +1,4 @@
1
+ import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js'
1
2
  import { spawn } from "child_process"
2
3
  import { TapResponse, SwipeResponse } from "../types.js"
3
4
  import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
@@ -31,7 +32,8 @@ export class iOSInteract {
31
32
  }
32
33
 
33
34
  await new Promise<void>((resolve, reject) => {
34
- const proc = spawn(getIdbCmd(), args);
35
+ markDispatchStarted()
36
+ const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
35
37
  let stderr = '';
36
38
  proc.stderr.on('data', d => stderr += d.toString());
37
39
  proc.on('close', code => {
@@ -72,7 +74,8 @@ export class iOSInteract {
72
74
  }
73
75
 
74
76
  await new Promise<void>((resolve, reject) => {
75
- const proc = spawn(getIdbCmd(), args);
77
+ markDispatchStarted()
78
+ const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
76
79
  let stderr = '';
77
80
  proc.stderr.on('data', d => stderr += d.toString());
78
81
  proc.on('close', code => {
@@ -1,3 +1,4 @@
1
+ import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
1
2
  import { promises as fs } from 'fs'
2
3
  import { spawn } from 'child_process'
3
4
  import path from 'path'
@@ -149,16 +150,18 @@ export class AndroidManage {
149
150
  }
150
151
  }
151
152
 
152
- async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
153
+ async startApp(appId: string, deviceId?: string, timeoutMs?: number): Promise<StartAppResponse> {
154
+ const started = performance.now()
153
155
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
154
156
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
155
157
  try {
156
- const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
158
+ markDispatchStarted()
159
+ const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs })
157
160
  const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null)
158
161
  return {
159
162
  device: deviceInfo,
160
163
  appStarted: true,
161
- launchTimeMs: 1000,
164
+ launchTimeMs: performance.now() - started,
162
165
  output,
163
166
  observedApp: {
164
167
  appId,
@@ -169,8 +172,8 @@ export class AndroidManage {
169
172
  }
170
173
  }
171
174
  } catch (e: unknown) {
172
- const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
173
- return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
175
+ const diagnostics = timeoutMs === undefined ? execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId) : undefined
176
+ return { device: deviceInfo, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) }
174
177
  }
175
178
  }
176
179
 
@@ -178,17 +181,22 @@ export class AndroidManage {
178
181
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
179
182
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
180
183
  try {
184
+ markDispatchStarted()
181
185
  await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
182
186
  return { device: deviceInfo, appTerminated: true }
183
187
  } catch (e: unknown) {
184
- const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
185
- return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
188
+ const diagnostics = hasFiniteBudget() ? undefined : execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
189
+ return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) }
186
190
  }
187
191
  }
188
192
 
189
- async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
190
- const terminateResult = await this.terminateApp(appId, deviceId)
191
- const startResult = await this.startApp(appId, deviceId)
193
+ async restartApp(appId: string, deviceId?: string, bounded = false, timeoutMs = 15000): Promise<RestartAppResponse> {
194
+ const started = performance.now()
195
+ const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(appId, deviceId)) : await this.terminateApp(appId, deviceId)
196
+ const remaining = timeoutMs - (performance.now() - started)
197
+ if (bounded && remaining <= 0) throw new Error('ACTION_TIMEOUT')
198
+ const launchBudget = remainingBudget(remaining)
199
+ const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(appId, deviceId, remainingBudget(launchBudget))) : await this.startApp(appId, deviceId)
192
200
  return {
193
201
  device: startResult.device,
194
202
  appRestarted: startResult.appStarted,
@@ -206,6 +214,7 @@ export class AndroidManage {
206
214
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
207
215
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
208
216
  try {
217
+ markDispatchStarted()
209
218
  const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
210
219
  return { device: deviceInfo, dataCleared: output === 'Success' }
211
220
  } catch (e: unknown) {
package/src/manage/ios.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
1
2
  import { promises as fs } from "fs"
2
3
  import { spawn, spawnSync } from "child_process"
3
4
  import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
@@ -310,17 +311,19 @@ export class iOSManage {
310
311
  }
311
312
  }
312
313
 
313
- async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
314
+ async startApp(bundleId: string, deviceId: string = "booted", observe = true): Promise<StartAppResponse> {
315
+ const started = performance.now()
314
316
  validateBundleId(bundleId)
315
317
  try {
318
+ markDispatchStarted()
316
319
  const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
317
320
  const device = await getIOSDeviceMetadata(deviceId)
318
- const fingerprint = await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null)
321
+ const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null
319
322
  const pidMatch = result.output.match(/:\s*(\d+)\s*$/)
320
323
  return {
321
324
  device,
322
325
  appStarted: !!result.output,
323
- launchTimeMs: 1000,
326
+ launchTimeMs: performance.now() - started,
324
327
  output: result.output,
325
328
  observedApp: {
326
329
  appId: bundleId,
@@ -330,28 +333,33 @@ export class iOSManage {
330
333
  }
331
334
  }
332
335
  } catch (e: unknown) {
333
- const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
334
336
  const device = await getIOSDeviceMetadata(deviceId)
335
- return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
337
+ const diagnostics = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
338
+ return { device, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) } as any
336
339
  }
337
340
  }
338
341
 
339
342
  async terminateApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
340
343
  validateBundleId(bundleId)
341
344
  try {
345
+ markDispatchStarted()
342
346
  await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
343
347
  const device = await getIOSDeviceMetadata(deviceId)
344
348
  return { device, appTerminated: true }
345
349
  } catch (e: unknown) {
346
- const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
350
+ const diag = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
347
351
  const device = await getIOSDeviceMetadata(deviceId)
348
- return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
352
+ return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diag ? { diagnostics: diag } : {}) } as any
349
353
  }
350
354
  }
351
355
 
352
- async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
353
- const terminateResult = await this.terminateApp(bundleId, deviceId)
354
- const startResult = await this.startApp(bundleId, deviceId)
356
+ async restartApp(bundleId: string, deviceId: string = "booted", observe = true, bounded = false, timeoutMs = 15000): Promise<RestartAppResponse> {
357
+ const started = performance.now()
358
+ const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(bundleId, deviceId)) : await this.terminateApp(bundleId, deviceId)
359
+ const remaining = timeoutMs - (performance.now() - started)
360
+ if (bounded && remaining <= 0) throw new Error('ACTION_TIMEOUT')
361
+ const launchBudget = remainingBudget(remaining)
362
+ const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(bundleId, deviceId, observe)) : await this.startApp(bundleId, deviceId, observe)
355
363
  return {
356
364
  device: startResult.device,
357
365
  appRestarted: startResult.appStarted,
@@ -0,0 +1,170 @@
1
+ import { execFile, spawn, spawnSync, type ChildProcess } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { connect } from 'node:net'
4
+ import path from 'node:path'
5
+ import { execAdb, getAdbCmd } from '../utils/android/utils.js'
6
+ import { remainingBudget } from '../utils/operation-budget.js'
7
+
8
+ const packageName = 'dev.mobiledebugmcp.treebridge'
9
+ const component = `${packageName}/.TreeInstrumentation`
10
+ const devicePort = 39019
11
+
12
+ interface Bridge {
13
+ deviceId: string
14
+ hostPort: number
15
+ child: ChildProcess
16
+ closed: boolean
17
+ childClosed: boolean
18
+ }
19
+
20
+ const active = new Map<string, Bridge>()
21
+ const starting = new Map<string, Promise<Bridge>>()
22
+ const stopping = new Map<string, Promise<void>>()
23
+
24
+ export function androidTreeBridgeEnabled(): boolean {
25
+ return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK)
26
+ }
27
+
28
+ function configuredApk() {
29
+ const configured = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK
30
+ if (!configured) return null
31
+ const apk = path.resolve(configured)
32
+ if (!existsSync(apk)) throw new Error(`Android UI tree bridge APK does not exist: ${apk}`)
33
+ return apk
34
+ }
35
+
36
+ function unbudgetedAdb(deviceId: string, args: string[]): Promise<void> {
37
+ return new Promise((resolve, reject) => {
38
+ execFile(getAdbCmd(), ['-s', deviceId, ...args], { timeout: 5000 }, (error) => error ? reject(error) : resolve())
39
+ })
40
+ }
41
+
42
+ function request(bridge: Bridge, command: 'PING' | 'TREE' | 'STOP', timeoutMs: number): Promise<string> {
43
+ return new Promise((resolve, reject) => {
44
+ let output = ''
45
+ const socket = connect(bridge.hostPort, '127.0.0.1', () => socket.write(`${command}\n`))
46
+ socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`Android UI tree bridge ${command} timed out`)))
47
+ socket.on('data', (chunk) => {
48
+ output += chunk.toString()
49
+ if (command === 'STOP' && output.startsWith('OK\n')) {
50
+ resolve(output)
51
+ socket.destroy()
52
+ }
53
+ })
54
+ socket.on('error', reject)
55
+ socket.on('end', () => resolve(output))
56
+ })
57
+ }
58
+
59
+ function waitForChildClose(bridge: Bridge, timeoutMs: number): Promise<void> {
60
+ if (bridge.childClosed) return Promise.resolve()
61
+ return new Promise((resolve, reject) => {
62
+ const onClose = () => { clearTimeout(timer); resolve() }
63
+ const timer = setTimeout(() => {
64
+ bridge.child.off('close', onClose)
65
+ reject(new Error('Android UI tree bridge child did not exit'))
66
+ }, timeoutMs)
67
+ bridge.child.once('close', onClose)
68
+ })
69
+ }
70
+
71
+ async function stop(bridge: Bridge): Promise<void> {
72
+ const pending = stopping.get(bridge.deviceId)
73
+ if (pending) return pending
74
+ bridge.closed = true
75
+ const cleanup = (async () => {
76
+ await request(bridge, 'STOP', 1000).catch(() => '')
77
+ let forceStopError: unknown
78
+ try { await unbudgetedAdb(bridge.deviceId, ['shell', 'am', 'force-stop', packageName]) }
79
+ catch (error) { forceStopError = error }
80
+ try { await waitForChildClose(bridge, 1000) }
81
+ catch {
82
+ bridge.child.kill('SIGTERM')
83
+ try { await waitForChildClose(bridge, 1000) }
84
+ catch {
85
+ bridge.child.kill('SIGKILL')
86
+ await waitForChildClose(bridge, 2000).catch(() => {})
87
+ }
88
+ }
89
+ let forwardError: unknown
90
+ try { await unbudgetedAdb(bridge.deviceId, ['forward', '--remove', `tcp:${bridge.hostPort}`]) }
91
+ catch (error) { forwardError = error }
92
+ if (forceStopError || !bridge.childClosed || forwardError) {
93
+ const reason = forceStopError ?? (!bridge.childClosed ? 'instrumentation child did not close' : forwardError)
94
+ throw new Error(`BRIDGE_CLEANUP_UNCONFIRMED: ${reason instanceof Error ? reason.message : String(reason)}`)
95
+ }
96
+ if (active.get(bridge.deviceId) === bridge) active.delete(bridge.deviceId)
97
+ })()
98
+ stopping.set(bridge.deviceId, cleanup)
99
+ try { await cleanup } finally { stopping.delete(bridge.deviceId) }
100
+ }
101
+
102
+ async function launch(deviceId: string, apk: string): Promise<Bridge> {
103
+ await unbudgetedAdb(deviceId, ['shell', 'am', 'force-stop', packageName])
104
+ try {
105
+ await execAdb(['install', '-r', apk], deviceId)
106
+ } catch (error) {
107
+ if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE')) throw error
108
+ await execAdb(['uninstall', packageName], deviceId)
109
+ await execAdb(['install', '-r', apk], deviceId)
110
+ }
111
+ const hostPort = Number(await execAdb(['forward', 'tcp:0', `tcp:${devicePort}`], deviceId))
112
+ if (!Number.isInteger(hostPort) || hostPort <= 0) throw new Error('Android UI tree bridge could not obtain a host port')
113
+ const child = spawn(getAdbCmd(), ['-s', deviceId, 'shell', 'am', 'instrument', '-w', component], { stdio: 'ignore' })
114
+ const bridge: Bridge = { deviceId, hostPort, child, closed: false, childClosed: false }
115
+ child.once('close', () => { bridge.childClosed = true })
116
+ child.once('error', () => { /* Startup detects the failed child before a tree read. */ })
117
+ active.set(deviceId, bridge)
118
+ try {
119
+ const deadline = Date.now() + Math.min(remainingBudget(3000), 3000)
120
+ while (Date.now() < deadline) {
121
+ if (bridge.childClosed || child.exitCode !== null || child.signalCode !== null) throw new Error('Android UI tree bridge exited during startup')
122
+ try {
123
+ if ((await request(bridge, 'PING', 250)).startsWith('OK\n')) {
124
+ return bridge
125
+ }
126
+ } catch { /* The instrumentation process may still be starting. */ }
127
+ await new Promise((resolve) => setTimeout(resolve, 100))
128
+ }
129
+ throw new Error('Android UI tree bridge did not become ready')
130
+ } catch (error) {
131
+ await stop(bridge)
132
+ throw error
133
+ }
134
+ }
135
+
136
+ async function ensure(deviceId: string, apk: string): Promise<Bridge> {
137
+ const pending = starting.get(deviceId)
138
+ if (pending) return pending
139
+ const ready = active.get(deviceId)
140
+ if (ready && !ready.closed && !ready.childClosed && ready.child.exitCode === null && ready.child.signalCode === null) return ready
141
+ if (ready) await stop(ready)
142
+ const promise = launch(deviceId, apk)
143
+ starting.set(deviceId, promise)
144
+ try { return await promise } finally { starting.delete(deviceId) }
145
+ }
146
+
147
+ /** Returns null only after the bridge has been stopped, so legacy acquisition is safe. */
148
+ export async function readAndroidTreeFromBridge(deviceId: string): Promise<string | null> {
149
+ const apk = configuredApk()
150
+ if (!apk) return null
151
+ let bridge: Bridge | undefined
152
+ try {
153
+ bridge = await ensure(deviceId, apk)
154
+ const output = await request(bridge, 'TREE', remainingBudget(10000))
155
+ if (!output.startsWith('OK\n') || !output.includes('<hierarchy')) throw new Error('Android UI tree bridge returned invalid hierarchy output')
156
+ return output.slice(3)
157
+ } catch (error) {
158
+ if (error instanceof Error && error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')) throw error
159
+ if (bridge) await stop(bridge)
160
+ if (error instanceof Error && error.message === 'ACTION_TIMEOUT') throw error
161
+ return null
162
+ }
163
+ }
164
+
165
+ process.once('exit', () => {
166
+ for (const bridge of active.values()) {
167
+ spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'shell', 'am', 'force-stop', packageName], { timeout: 2000, stdio: 'ignore' })
168
+ spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'forward', '--remove', `tcp:${bridge.hostPort}`], { timeout: 2000, stdio: 'ignore' })
169
+ }
170
+ })