mobile-debug-mcp 0.30.2 → 0.32.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 (87) hide show
  1. package/README.md +50 -5
  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 +20 -11
  7. package/dist/observe/android.js +6 -1
  8. package/dist/observe/index.js +22 -5
  9. package/dist/observe/ios.js +7 -2
  10. package/dist/observe/snapshot-metadata.js +94 -15
  11. package/dist/server/common.js +14 -7
  12. package/dist/server/journey-schema.js +60 -0
  13. package/dist/server/tool-definitions.js +59 -6
  14. package/dist/server/tool-handlers.js +358 -55
  15. package/dist/server-core.js +1 -1
  16. package/dist/system/index.js +485 -48
  17. package/dist/system/ios.js +15 -7
  18. package/dist/utils/android/utils.js +4 -4
  19. package/dist/utils/cli/idb/check-idb.js +10 -3
  20. package/dist/utils/cli/idb/idb-helper.js +47 -13
  21. package/dist/utils/exec.js +4 -2
  22. package/dist/utils/ios/utils.js +10 -11
  23. package/dist/utils/operation-budget.js +25 -0
  24. package/dist/utils/resolve-device.js +12 -1
  25. package/dist/utils/timing.js +19 -0
  26. package/dist/utils/ui/index.js +2 -0
  27. package/docs/CHANGELOG.md +6 -0
  28. package/docs/ROADMAP.md +4 -4
  29. package/docs/benchmarks/interaction-latency/README.md +26 -0
  30. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  31. package/docs/benchmarks/interaction-latency/final.md +17 -0
  32. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  33. package/docs/specs/baseline-spec-v0.md +1 -1
  34. package/docs/specs-deltas/016-environment-tools.md +533 -0
  35. package/docs/specs-deltas/018-interaction-latency-and-batched-journeys.md +301 -0
  36. package/docs/tools/TOOLS.md +2 -0
  37. package/docs/tools/interact.md +30 -0
  38. package/docs/tools/manage.md +4 -2
  39. package/docs/tools/system.md +9 -2
  40. package/package.json +1 -1
  41. package/src/interact/android.ts +13 -8
  42. package/src/interact/index.ts +32 -23
  43. package/src/interact/ios.ts +5 -2
  44. package/src/manage/android.ts +19 -10
  45. package/src/manage/ios.ts +19 -11
  46. package/src/observe/android.ts +5 -1
  47. package/src/observe/index.ts +21 -5
  48. package/src/observe/ios.ts +6 -2
  49. package/src/observe/snapshot-metadata.ts +94 -15
  50. package/src/server/common.ts +13 -6
  51. package/src/server/journey-schema.ts +63 -0
  52. package/src/server/tool-definitions.ts +60 -7
  53. package/src/server/tool-handlers.ts +324 -53
  54. package/src/server-core.ts +1 -1
  55. package/src/system/index.ts +572 -50
  56. package/src/system/ios.ts +14 -7
  57. package/src/types.ts +31 -2
  58. package/src/utils/android/utils.ts +3 -3
  59. package/src/utils/cli/idb/check-idb.ts +11 -2
  60. package/src/utils/cli/idb/idb-helper.ts +49 -12
  61. package/src/utils/exec.ts +4 -2
  62. package/src/utils/ios/utils.ts +9 -10
  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/fixtures/latency-journey-app/AndroidManifest.xml +6 -0
  70. package/test/fixtures/latency-journey-app/README.md +10 -0
  71. package/test/fixtures/latency-journey-app/build.ts +26 -0
  72. package/test/fixtures/latency-journey-app/src/MainActivity.java +48 -0
  73. package/test/unit/interact/tap_element.test.ts +1 -1
  74. package/test/unit/manage/restart_budget.test.ts +37 -0
  75. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  76. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  77. package/test/unit/observe/state_delta.test.ts +15 -0
  78. package/test/unit/server/benchmark_report.test.ts +4 -0
  79. package/test/unit/server/contract.test.ts +1 -1
  80. package/test/unit/server/interaction_modes.test.ts +60 -0
  81. package/test/unit/server/operation_budget.test.ts +32 -0
  82. package/test/unit/server/response_shapes.test.ts +3 -3
  83. package/test/unit/server/run_journey.test.ts +90 -0
  84. package/test/unit/server/tap_selector.test.ts +68 -0
  85. package/test/unit/system/get_system_status.test.ts +3 -0
  86. package/test/unit/system/system_status.test.ts +40 -0
  87. package/test/unit/utils/idb_health.test.ts +74 -0
@@ -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"
@@ -283,7 +284,7 @@ export class iOSManage {
283
284
  // Gather diagnostics for simctl failure
284
285
  const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId)
285
286
  try {
286
- const child = spawn(getIdbCmd(), ['--version'])
287
+ const child = spawn(getIdbCmd(), ['list-targets', '--json'])
287
288
  const idbExists = await new Promise<boolean>((resolve) => {
288
289
  child.on('error', () => resolve(false));
289
290
  child.on('close', (code) => resolve(code === 0));
@@ -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,
@@ -8,6 +8,7 @@ import path from "path"
8
8
  import { computeScreenFingerprint } from "../utils/ui/index.js"
9
9
  import { parsePngSize } from "../utils/image.js"
10
10
  import { deriveSnapshotMetadata } from "./snapshot-metadata.js"
11
+ import { remainingBudget } from '../utils/operation-budget.js'
11
12
 
12
13
  const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
13
14
 
@@ -33,10 +34,12 @@ export class AndroidObserve {
33
34
  const maxAttempts = 3;
34
35
 
35
36
  while (attempts < maxAttempts) {
37
+ remainingBudget()
36
38
  attempts++;
37
39
  try {
38
40
  // Stabilization delay
39
- await delay(300 + (attempts * 100)); // 300ms, 400ms, 500ms...
41
+ await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))); // 300ms, 400ms, 500ms...
42
+ remainingBudget()
40
43
 
41
44
  // Dump UI hierarchy
42
45
  await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
@@ -49,6 +52,7 @@ export class AndroidObserve {
49
52
  break; // Success
50
53
  }
51
54
  } catch (e) {
55
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
52
56
  console.error(`Attempt ${attempts} failed: ${e}`);
53
57
  }
54
58
 
@@ -1,3 +1,4 @@
1
+ import { measure } from '../utils/timing.js'
1
2
  import { resolveTargetDevice } from '../utils/resolve-device.js'
2
3
  import { AndroidObserve } from './android.js'
3
4
  import { iOSObserve } from './ios.js'
@@ -5,7 +6,7 @@ import type {
5
6
  CaptureDebugSnapshotRawResponse,
6
7
  SnapshotSemanticResponse
7
8
  } from '../types.js'
8
- import { deriveSnapshotMetadata } from './snapshot-metadata.js'
9
+ import { deriveSnapshotMetadata, getLatestStateDelta, getStateDelta } from './snapshot-metadata.js'
9
10
 
10
11
  export { AndroidObserve } from './android.js'
11
12
  export { iOSObserve } from './ios.js'
@@ -149,6 +150,9 @@ function deriveSnapshotSemantic(raw: CaptureDebugSnapshotRawResponse): SnapshotS
149
150
  }
150
151
 
151
152
  export class ToolsObserve {
153
+ static getLatestStateDelta(platform: 'android' | 'ios', deviceId: string) {
154
+ return getLatestStateDelta(`${platform}:${deviceId}`)
155
+ }
152
156
  // Resolve a target device and return the appropriate observe instance and resolved info.
153
157
  private static async resolveObserve(platform?: 'android' | 'ios', deviceId?: string, appId?: string) {
154
158
  if (platform === 'android') {
@@ -170,9 +174,21 @@ export class ToolsObserve {
170
174
  }
171
175
  }
172
176
 
173
- static async getUITreeHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
177
+ static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }: { platform?: 'android' | 'ios', deviceId?: string, responseMode?: 'compact' | 'debug', sinceSnapshotRevision?: number }) {
174
178
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
175
- return await observe.getUITree(resolved.id)
179
+ const tree = await observe.getUITree(resolved.id) as any
180
+ if (responseMode !== 'compact') return tree
181
+ const revision = tree?.snapshot_revision ?? null
182
+ const baseRevision = sinceSnapshotRevision
183
+ if (!Number.isInteger(baseRevision) || revision === null) {
184
+ return { delta_available: false, snapshot_revision: revision }
185
+ }
186
+ const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision as number, revision)
187
+ return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
188
+ delta_available: false,
189
+ snapshot_revision: revision,
190
+ reason: 'BASE_REVISION_UNAVAILABLE'
191
+ }
176
192
  }
177
193
 
178
194
  static async getCurrentScreenHandler({ deviceId }: { deviceId?: string }) {
@@ -244,7 +260,7 @@ export class ToolsObserve {
244
260
  return await (observe as any).getScreenFingerprint(resolved.id)
245
261
  }
246
262
 
247
- static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
263
+ static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
248
264
  const timestamp = Date.now()
249
265
  const raw: CaptureDebugSnapshotRawResponse = {
250
266
  timestamp,
@@ -261,7 +277,7 @@ export class ToolsObserve {
261
277
  // Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
262
278
  const sid = sessionId || 'default'
263
279
  const tasks: Record<string, Promise<any>> = {
264
- screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
280
+ screenshot: includeScreenshot ? measure('screenshot_ms', () => ToolsObserve.captureScreenshotHandler({ platform, deviceId })) : Promise.resolve(null),
265
281
  currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
266
282
  fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
267
283
  uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "child_process"
2
+ import { remainingBudget } from '../utils/operation-budget.js'
2
3
  import { promises as fs } from "fs"
3
4
  import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../types.js"
4
5
  import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
@@ -547,9 +548,11 @@ export class iOSObserve {
547
548
  const maxAttempts = 3;
548
549
 
549
550
  while (attempts < maxAttempts) {
551
+ remainingBudget()
550
552
  attempts++;
551
553
  try {
552
- await delay(300 + (attempts * 100));
554
+ await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100)));
555
+ remainingBudget()
553
556
 
554
557
  const args = ['ui', 'describe-all', '--json'];
555
558
  if (targetUdid) {
@@ -557,7 +560,7 @@ export class iOSObserve {
557
560
  }
558
561
 
559
562
  const output = await new Promise<string>((resolve, reject) => {
560
- const child = spawn(getIdbCmd(), args);
563
+ const child = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
561
564
  let stdout = '';
562
565
  let stderr = '';
563
566
 
@@ -580,6 +583,7 @@ export class iOSObserve {
580
583
  break; // Success
581
584
  }
582
585
  } catch (e) {
586
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
583
587
  console.error(`Attempt ${attempts} failed: ${e}`);
584
588
  }
585
589
 
@@ -5,8 +5,15 @@ interface SnapshotState {
5
5
  revision: number
6
6
  signature: string | null
7
7
  elementSignatures: Map<string, string>
8
+ elements: Map<string, UIElement>
9
+ history: Map<number, Map<string, UIElement>>
10
+ historyTimes: Map<number, number>
11
+ oversized: Map<number, number>
12
+ previousRevision: number | null
13
+ updatedAt: number
8
14
  }
9
15
 
16
+ let nextRevision = 1
10
17
  const snapshotStateByDevice = new Map<string, SnapshotState>()
11
18
 
12
19
  function normalize(value: unknown): string {
@@ -53,18 +60,20 @@ function stableElementIdentity(element: UIElement, index: number) {
53
60
  })).digest('hex')}`
54
61
  }
55
62
 
56
- function buildElementSignatures(tree: Pick<GetUITreeResponse, 'elements'> | null | undefined) {
63
+ function buildElementRecords(tree: Pick<GetUITreeResponse, 'elements'> | null | undefined) {
57
64
  const signatures = new Map<string, string>()
65
+ const elementsByIdentity = new Map<string, UIElement>()
58
66
  const elements = Array.isArray(tree?.elements) ? tree!.elements! : []
59
67
 
60
- for (let index = 0; index < elements.length; index++) {
68
+ for (let index = 0; index < Math.min(elements.length, 500); index++) {
61
69
  const element = elements[index]
62
70
  if (!element) continue
63
71
  const identity = stableElementIdentity(element, index)
64
72
  signatures.set(identity, crypto.createHash('sha1').update(JSON.stringify(stableElementSignature(element))).digest('hex'))
73
+ elementsByIdentity.set(identity, structuredClone(element))
65
74
  }
66
75
 
67
- return signatures
76
+ return { signatures, elementsByIdentity }
68
77
  }
69
78
 
70
79
  function summarizeSnapshotDelta(previous: SnapshotState | undefined, currentElements: Map<string, string>): SnapshotDelta | null {
@@ -140,25 +149,45 @@ export function deriveSnapshotMetadata(
140
149
  signatureOverride?: string | null
141
150
  ) {
142
151
  const signature = signatureOverride ?? computeSnapshotSignature(tree)
143
- const previous = snapshotStateByDevice.get(deviceKey)
152
+ const existing = snapshotStateByDevice.get(deviceKey)
153
+ const previous = existing && Date.now() - existing.updatedAt <= 10 * 60 * 1000 ? existing : undefined
154
+ if (!previous && existing) snapshotStateByDevice.delete(deviceKey)
144
155
  const hasValidTree = !!tree && !tree.error
145
- const currentElementSignatures = hasValidTree
146
- ? buildElementSignatures(tree)
147
- : previous?.elementSignatures ?? new Map<string, string>()
148
-
149
- let revision = 1
150
- if (previous) {
151
- if (signature === null) {
152
- revision = previous.revision
153
- } else {
154
- revision = previous.signature === signature ? previous.revision : previous.revision + 1
156
+ const records = hasValidTree ? buildElementRecords(tree) : null
157
+ const currentElementSignatures = records?.signatures ?? previous?.elementSignatures ?? new Map<string, string>()
158
+ const currentElements = records?.elementsByIdentity ?? previous?.elements ?? new Map<string, UIElement>()
159
+
160
+ const revision = previous && (signature === null || previous.signature === signature) ? previous.revision : nextRevision++
161
+ const history = previous?.history ?? new Map<number, Map<string, UIElement>>()
162
+ const historyTimes = previous?.historyTimes ?? new Map<number, number>()
163
+ const oversized = previous?.oversized ?? new Map<number, number>()
164
+ if (hasValidTree) {
165
+ history.set(revision, currentElements)
166
+ if (!historyTimes.has(revision)) historyTimes.set(revision, Date.now())
167
+ if ((tree.elements?.length ?? 0) > 500) oversized.set(revision, (tree.elements?.length ?? 0) - 500)
168
+ }
169
+ for (const [id, timestamp] of historyTimes) {
170
+ if (Date.now() - timestamp > 10 * 60 * 1000) {
171
+ history.delete(id)
172
+ historyTimes.delete(id)
173
+ oversized.delete(id)
155
174
  }
156
175
  }
176
+ while (history.size > 8) {
177
+ const oldest = history.keys().next().value
178
+ if (oldest === undefined) break
179
+ history.delete(oldest)
180
+ historyTimes.delete(oldest)
181
+ oversized.delete(oldest)
182
+ }
157
183
 
158
184
  snapshotStateByDevice.set(deviceKey, {
159
185
  revision,
160
186
  signature,
161
- elementSignatures: currentElementSignatures
187
+ elementSignatures: currentElementSignatures,
188
+ elements: currentElements,
189
+ history, historyTimes, oversized, previousRevision: previous?.revision ?? null,
190
+ updatedAt: Date.now()
162
191
  })
163
192
 
164
193
  return {
@@ -169,6 +198,56 @@ export function deriveSnapshotMetadata(
169
198
  }
170
199
  }
171
200
 
201
+ export function getStateDelta(deviceKey: string, baseRevision: number, currentRevision: number) {
202
+ const state = snapshotStateByDevice.get(deviceKey)
203
+ if (!state || Date.now() - (state.historyTimes.get(baseRevision) ?? 0) > 10 * 60 * 1000 || Date.now() - state.updatedAt > 10 * 60 * 1000) return null
204
+ const base = state.history.get(baseRevision)
205
+ const current = state?.history.get(currentRevision)
206
+ if (!base || !current) return null
207
+
208
+ const added: UIElement[] = []
209
+ const changed: UIElement[] = []
210
+ const removed: Array<{ stable_id?: string, resourceId?: string, contentDescription?: string, text?: string, index: number }> = []
211
+ for (const [identity, element] of current) {
212
+ const before = base.get(identity)
213
+ if (!before) added.push(element)
214
+ else if (JSON.stringify(stableElementSignature(before)) !== JSON.stringify(stableElementSignature(element))) changed.push(element)
215
+ }
216
+ let index = 0
217
+ for (const [identity, element] of base) {
218
+ if (!current.has(identity)) removed.push({ stable_id: element.stable_id ?? undefined, resourceId: element.resourceId ?? undefined, contentDescription: element.contentDescription ?? undefined, text: element.text ?? undefined, index })
219
+ index++
220
+ }
221
+ const all = added.length + changed.length + removed.length
222
+ const limit = 200
223
+ return {
224
+ base_snapshot_revision: baseRevision,
225
+ snapshot_revision: currentRevision,
226
+ added: added.slice(0, limit),
227
+ changed: changed.slice(0, Math.max(0, limit - added.length)),
228
+ removed: removed.slice(0, Math.max(0, limit - added.length - changed.length)),
229
+ truncated: all > limit || state.oversized.has(baseRevision) || state.oversized.has(currentRevision),
230
+ ...((all > limit || state.oversized.has(baseRevision) || state.oversized.has(currentRevision))
231
+ ? { omitted_changes: Math.max(all - limit, state.oversized.get(baseRevision) ?? 0, state.oversized.get(currentRevision) ?? 0) } : {})
232
+ }
233
+ }
234
+
235
+ export function getLatestStateDelta(deviceKey: string) {
236
+ const state = snapshotStateByDevice.get(deviceKey)
237
+ if (!state || state.previousRevision === null) return null
238
+ return getStateDelta(deviceKey, state.previousRevision, state.revision)
239
+ }
240
+
241
+ export function retainConnectedDeviceSnapshots(platform: string, deviceIds: string[]) {
242
+ const keys = new Set(deviceIds.map(id => `${platform}:${id}`))
243
+ for (const key of snapshotStateByDevice.keys()) if (key.startsWith(`${platform}:`) && !keys.has(key)) snapshotStateByDevice.delete(key)
244
+ }
245
+
246
+ export function clearDeviceSnapshots(deviceKey: string) {
247
+ snapshotStateByDevice.delete(deviceKey)
248
+ }
249
+
172
250
  export function resetSnapshotMetadataForTests() {
173
251
  snapshotStateByDevice.clear()
252
+ nextRevision = 1
174
253
  }