mobile-debug-mcp 0.31.0 → 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 (66) hide show
  1. package/dist/interact/android.js +13 -8
  2. package/dist/interact/index.js +33 -22
  3. package/dist/interact/ios.js +5 -2
  4. package/dist/manage/android.js +20 -10
  5. package/dist/manage/ios.js +19 -10
  6. package/dist/observe/android.js +6 -1
  7. package/dist/observe/index.js +22 -5
  8. package/dist/observe/ios.js +7 -2
  9. package/dist/observe/snapshot-metadata.js +94 -15
  10. package/dist/server/common.js +14 -7
  11. package/dist/server/journey-schema.js +60 -0
  12. package/dist/server/tool-definitions.js +59 -6
  13. package/dist/server/tool-handlers.js +358 -55
  14. package/dist/server-core.js +1 -1
  15. package/dist/utils/exec.js +4 -2
  16. package/dist/utils/ios/utils.js +5 -2
  17. package/dist/utils/operation-budget.js +25 -0
  18. package/dist/utils/resolve-device.js +12 -1
  19. package/dist/utils/timing.js +19 -0
  20. package/dist/utils/ui/index.js +2 -0
  21. package/docs/CHANGELOG.md +3 -0
  22. package/docs/benchmarks/interaction-latency/README.md +26 -0
  23. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  24. package/docs/benchmarks/interaction-latency/final.md +17 -0
  25. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  26. package/docs/specs-deltas/018-interaction-latency-and-batched-journeys.md +301 -0
  27. package/docs/tools/interact.md +30 -0
  28. package/package.json +1 -1
  29. package/src/interact/android.ts +13 -8
  30. package/src/interact/index.ts +32 -23
  31. package/src/interact/ios.ts +5 -2
  32. package/src/manage/android.ts +19 -10
  33. package/src/manage/ios.ts +18 -10
  34. package/src/observe/android.ts +5 -1
  35. package/src/observe/index.ts +21 -5
  36. package/src/observe/ios.ts +6 -2
  37. package/src/observe/snapshot-metadata.ts +94 -15
  38. package/src/server/common.ts +13 -6
  39. package/src/server/journey-schema.ts +63 -0
  40. package/src/server/tool-definitions.ts +60 -7
  41. package/src/server/tool-handlers.ts +324 -53
  42. package/src/server-core.ts +1 -1
  43. package/src/types.ts +31 -2
  44. package/src/utils/exec.ts +4 -2
  45. package/src/utils/ios/utils.ts +5 -2
  46. package/src/utils/operation-budget.ts +22 -0
  47. package/src/utils/resolve-device.ts +13 -1
  48. package/src/utils/timing.ts +15 -0
  49. package/src/utils/ui/index.ts +2 -0
  50. package/test/device/automated/latency/benchmark.ts +136 -0
  51. package/test/device/automated/latency/report.ts +6 -0
  52. package/test/fixtures/latency-journey-app/AndroidManifest.xml +6 -0
  53. package/test/fixtures/latency-journey-app/README.md +10 -0
  54. package/test/fixtures/latency-journey-app/build.ts +26 -0
  55. package/test/fixtures/latency-journey-app/src/MainActivity.java +48 -0
  56. package/test/unit/manage/restart_budget.test.ts +37 -0
  57. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  58. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  59. package/test/unit/observe/state_delta.test.ts +15 -0
  60. package/test/unit/server/benchmark_report.test.ts +4 -0
  61. package/test/unit/server/contract.test.ts +1 -1
  62. package/test/unit/server/interaction_modes.test.ts +60 -0
  63. package/test/unit/server/operation_budget.test.ts +32 -0
  64. package/test/unit/server/response_shapes.test.ts +3 -3
  65. package/test/unit/server/run_journey.test.ts +90 -0
  66. package/test/unit/server/tap_selector.test.ts +68 -0
@@ -1,3 +1,8 @@
1
+ import { measure, withTiming } from '../utils/timing.js'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { runWithBudget } from '../utils/operation-budget.js'
4
+ import { journeySchema, tapElementControlsSchema } from './journey-schema.js'
5
+ import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
1
6
  import type {
2
7
  InstallAppResponse,
3
8
  ResetAppDataResponse,
@@ -24,6 +29,7 @@ import {
24
29
  requireObjectArg,
25
30
  requireStringArg,
26
31
  ToolCallArgs,
32
+ ToolCallResult,
27
33
  ToolHandler,
28
34
  wrapResponse,
29
35
  wrapToolError
@@ -36,16 +42,107 @@ type WaitForUiMatchArg = { index?: number }
36
42
  type WaitForUiRetryArg = { max_attempts?: number, backoff_ms?: number }
37
43
  type ScrollSelectorArg = { text?: string, resourceId?: string, contentDesc?: string, className?: string }
38
44
  type ClassifyNetworkRequestArg = { endpoint: string, status: 'success' | 'failure' | 'retryable' }
45
+ type VerificationMode = 'none' | 'light' | 'full'
46
+ type ResponseMode = 'compact' | 'debug'
39
47
 
40
- async function handleStartApp(args: ToolCallArgs) {
48
+ function interactionControls(args: ToolCallArgs) {
49
+ const verificationMode = (getStringArg(args, 'verificationMode') as VerificationMode | undefined) ?? 'full'
50
+ const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
51
+ if (!['none', 'light', 'full'].includes(verificationMode) || !['compact', 'debug'].includes(responseMode)) throw new Error('INVALID_ARGUMENT')
52
+ for (const key of ['verificationMode', 'responseMode']) if (args[key] !== undefined && typeof args[key] !== 'string') throw new Error('INVALID_ARGUMENT')
53
+ for (const key of ['actionTimeoutMs', 'verificationTimeoutMs']) if (args[key] !== undefined && (typeof args[key] !== 'number' || !Number.isInteger(args[key]))) throw new Error('INVALID_ARGUMENT')
54
+ const actionTimeoutMs = getNumberArg(args, 'actionTimeoutMs')
55
+ const verificationTimeoutMs = getNumberArg(args, 'verificationTimeoutMs')
56
+ return { verificationMode, responseMode,
57
+ actionTimeoutMs: actionTimeoutMs === undefined ? undefined : Math.max(100, Math.min(30000, actionTimeoutMs)),
58
+ verificationTimeoutMs: verificationTimeoutMs === undefined ? 3000 : Math.max(100, Math.min(60000, verificationTimeoutMs)) }
59
+
60
+ }
61
+
62
+ function compactAction(result: any, verificationMode: VerificationMode, started = true, stateDelta?: unknown, totalMs = 0) {
63
+ return {
64
+ action_id: result.action_id,
65
+ action_type: result.action_type,
66
+ success: !!result.success,
67
+ lifecycle_state: result.success ? (verificationMode === 'full' ? result.lifecycle_state : 'pending_verification') : 'failed',
68
+ ...(result.failure_code ? { failure_code: result.failure_code } : {}),
69
+ ...(typeof result.retryable === 'boolean' ? { retryable: result.retryable } : {}),
70
+ delivery_status: result.success ? 'delivered' : (started ? 'unknown' : 'not_delivered'),
71
+ dispatch_started: started,
72
+ verification: { mode: verificationMode, status: result.success ? (verificationMode === 'none' ? 'not_requested' : verificationMode === 'light' ? 'observed' : (result.lifecycle_state === 'verified' ? 'verified' : 'observed')) : 'failed' },
73
+ ...(stateDelta ? { state_delta: stateDelta } : {}),
74
+ timing: { total_ms: totalMs }
75
+ }
76
+ }
77
+
78
+ function actionHandler(type: string, execute: ToolHandler): ToolHandler {
79
+ return async (args) => {
80
+ const timing: Record<string, number> = {}
81
+ return withTiming(timing, async () => {
82
+ const started = performance.now()
83
+ const controls = interactionControls(args)
84
+ const budgetMs = controls.actionTimeoutMs ?? ({ start_app: 10000, restart_app: 15000, scroll_to_element: 10000 }[type] ?? 5000)
85
+ let dispatchStarted = false
86
+ let result: any
87
+ const dispatchWithBudget = (timeoutMs: number) => runWithBudget(timeoutMs, async (budget) => {
88
+ try { return payloadOf(await execute(args)) }
89
+ finally { dispatchStarted = budget.dispatchStarted }
90
+ })
91
+ try {
92
+ // Omitted controls retain the existing adapter budgets.
93
+ result = args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined
94
+ ? await dispatchWithBudget(Infinity) : await dispatchWithBudget(budgetMs)
95
+ if (result.success) dispatchStarted = true
96
+ } catch (error) {
97
+ if (controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error))) return wrapToolError(type, error)
98
+ result = { success: false, failure_code: /TIMEOUT|timed out/i.test(String(error)) ? 'ACTION_TIMEOUT' : 'UNKNOWN', error: String(error) }
99
+ }
100
+ if (result.failure_code === 'TIMEOUT' || /ACTION_TIMEOUT|timeout/i.test(result.error ?? result.reason ?? result.details?.error ?? '')) result.failure_code = 'ACTION_TIMEOUT'
101
+ if (result.failure_code === 'ACTION_TIMEOUT') result.retryable = !dispatchStarted
102
+ if (!result.success && typeof result.retryable !== 'boolean') result.retryable = !dispatchStarted
103
+ result.action_id ??= randomUUID()
104
+ result.action_type ??= type
105
+ let delta: unknown
106
+ let observationAvailable = false
107
+ if (result.success && controls.verificationMode === 'light') {
108
+ const tree = await measure('post_observation_ms', () => runWithBudget(controls.verificationTimeoutMs, () =>
109
+ ToolsObserve.getUITreeHandler({ platform: (args.platform as PlatformArg | undefined) ?? result.device?.platform, deviceId: (args.deviceId as string | undefined) ?? result.device?.id }))).catch(() => null) as any
110
+ observationAvailable = !!tree && !tree.error
111
+ if (observationAvailable && tree.device) delta = ToolsObserve.getLatestStateDelta(tree.device.platform, tree.device.id) ?? undefined
112
+ }
113
+ const compact = compactAction(result, controls.verificationMode, dispatchStarted, delta, performance.now() - started)
114
+ if (result.success && controls.verificationMode === 'light' && !observationAvailable) compact.verification.status = 'unavailable'
115
+ const evidence = {
116
+ ...compact,
117
+ ...(controls.verificationMode === 'light' ? { state_delta_available: !!delta } : {}),
118
+ ...(result.success && controls.verificationMode === 'light' && !observationAvailable ? { verification_diagnostic: { code: 'VERIFICATION_UNAVAILABLE', retryable: true } } : {})
119
+ }
120
+ return wrapResponse(controls.responseMode === 'compact' ? evidence : {
121
+ ...result, ...evidence, timing: { ...timing, total_ms: performance.now() - started },
122
+ effective_controls: { ...controls, actionTimeoutMs: args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined ? null : budgetMs, timeout_bound: !(args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined) }
123
+ }, controls.responseMode === 'debug')
124
+ })
125
+ }
126
+ }
127
+ const handleStartApp = actionHandler('start_app', executeStartApp)
128
+ const handleRestartApp = actionHandler('restart_app', executeRestartApp)
129
+ const handleTap = actionHandler('tap', executeTap)
130
+ const handleTapElement = actionHandler('tap_element', executeTapElement)
131
+ const handleSwipe = actionHandler('swipe', executeSwipe)
132
+ const handleScrollToElement = actionHandler('scroll_to_element', executeScrollToElement)
133
+ const handleTypeText = actionHandler('type_text', executeTypeText)
134
+ const handlePressBack = actionHandler('press_back', executePressBack)
135
+
136
+ async function executeStartApp(args: ToolCallArgs) {
41
137
  const platform = requireStringArg(args, 'platform') as PlatformArg
42
138
  const appId = requireStringArg(args, 'appId')
43
139
  const deviceId = getStringArg(args, 'deviceId')
44
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
140
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
141
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
45
142
  ToolsNetwork.notifyActionStart()
46
- const res = await (platform === 'android' ? new AndroidManage().startApp(appId, deviceId) : new iOSManage().startApp(appId, deviceId))
47
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
48
- return wrapResponse(buildActionExecutionResult({
143
+ const res = await measure('dispatch_ms', () => (platform === 'android' ? new AndroidManage().startApp(appId, deviceId, actionTimeoutMs ?? (verificationMode === 'full' && getStringArg(args, 'responseMode') === undefined && args._journey !== true ? undefined : 10000)) : new iOSManage().startApp(appId, deviceId, verificationMode === 'full')))
144
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
145
+ const result = buildActionExecutionResult({
49
146
  actionType: 'start_app',
50
147
  sourceModule: 'server',
51
148
  device: res.device,
@@ -59,9 +156,11 @@ async function handleStartApp(args: ToolCallArgs) {
59
156
  ...(typeof res.output === 'string' ? { output: res.output } : {}),
60
157
  ...(res.device ? { device_id: res.device.id } : {}),
61
158
  ...(typeof res.error === 'string' ? { error: res.error } : {}),
159
+ ...(res.diagnostics ? { diagnostics: res.diagnostics } : {}),
62
160
  ...(res.observedApp ? { observed_app: res.observedApp } : {})
63
161
  }
64
- }))
162
+ })
163
+ return wrapResponse(result)
65
164
  }
66
165
 
67
166
  async function handleTerminateApp(args: ToolCallArgs) {
@@ -73,15 +172,17 @@ async function handleTerminateApp(args: ToolCallArgs) {
73
172
  return wrapResponse(response)
74
173
  }
75
174
 
76
- async function handleRestartApp(args: ToolCallArgs) {
175
+ async function executeRestartApp(args: ToolCallArgs) {
77
176
  const platform = requireStringArg(args, 'platform') as PlatformArg
78
177
  const appId = requireStringArg(args, 'appId')
79
178
  const deviceId = getStringArg(args, 'deviceId')
80
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
179
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
180
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
81
181
  ToolsNetwork.notifyActionStart()
82
- const res = await (platform === 'android' ? new AndroidManage().restartApp(appId, deviceId) : new iOSManage().restartApp(appId, deviceId))
83
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
84
- return wrapResponse(buildActionExecutionResult({
182
+ const bounded = args._journey === true || verificationMode !== 'full' || args.responseMode !== undefined || actionTimeoutMs !== undefined
183
+ const res = await measure('dispatch_ms', () => (platform === 'android' ? new AndroidManage().restartApp(appId, deviceId, bounded, actionTimeoutMs ?? 15000) : new iOSManage().restartApp(appId, deviceId, verificationMode === 'full', bounded, actionTimeoutMs ?? 15000)))
184
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
185
+ const result = buildActionExecutionResult({
85
186
  actionType: 'restart_app',
86
187
  sourceModule: 'server',
87
188
  device: res.device,
@@ -96,9 +197,11 @@ async function handleRestartApp(args: ToolCallArgs) {
96
197
  ...(typeof res.terminatedBeforeRestart === 'boolean' ? { terminated_before_restart: res.terminatedBeforeRestart } : {}),
97
198
  ...(typeof res.terminateError === 'string' ? { terminate_error: res.terminateError } : {}),
98
199
  ...(typeof res.error === 'string' ? { error: res.error } : {}),
200
+ ...(res.diagnostics ? { diagnostics: res.diagnostics } : {}),
99
201
  ...(res.observedApp ? { observed_app: res.observedApp } : {})
100
202
  }
101
- }))
203
+ })
204
+ return wrapResponse(result)
102
205
  }
103
206
 
104
207
  async function handleResetAppData(args: ToolCallArgs) {
@@ -185,10 +288,12 @@ async function handleGetSystemStatus() {
185
288
  async function handleCaptureScreenshot(args: ToolCallArgs) {
186
289
  const platform = requireStringArg(args, 'platform') as PlatformArg
187
290
  const deviceId = getStringArg(args, 'deviceId')
291
+ const started = performance.now()
188
292
  const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
293
+ const screenshotMs = performance.now() - started
189
294
  const mime = (res as any).screenshot_mime || 'image/png'
190
295
  const content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }> = [
191
- { type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime } }, null, 2) },
296
+ { type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime }, timing: { total_ms: screenshotMs, screenshot_ms: screenshotMs } }, null, 2) },
192
297
  { type: 'image', data: (res as any).screenshot, mimeType: mime }
193
298
  ]
194
299
  if ((res as any).screenshot_fallback) {
@@ -206,14 +311,17 @@ async function handleCaptureDebugSnapshot(args: ToolCallArgs) {
206
311
  const appId = getStringArg(args, 'appId')
207
312
  const deviceId = getStringArg(args, 'deviceId')
208
313
  const sessionId = getStringArg(args, 'sessionId')
209
- const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, logLines, platform, appId, deviceId, sessionId })
314
+ const includeScreenshot = getBooleanArg(args, 'includeScreenshot')
315
+ const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, includeScreenshot, logLines, platform, appId, deviceId, sessionId })
210
316
  return wrapResponse(res)
211
317
  }
212
318
 
213
319
  async function handleGetUITree(args: ToolCallArgs) {
214
320
  const platform = requireStringArg(args, 'platform') as PlatformArg
215
321
  const deviceId = getStringArg(args, 'deviceId')
216
- const res = await ToolsObserve.getUITreeHandler({ platform, deviceId })
322
+ const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
323
+ const sinceSnapshotRevision = getNumberArg(args, 'sinceSnapshotRevision')
324
+ const res = await ToolsObserve.getUITreeHandler({ platform, deviceId, responseMode, sinceSnapshotRevision })
217
325
  return wrapResponse(res)
218
326
  }
219
327
 
@@ -337,16 +445,17 @@ async function handleFindElement(args: ToolCallArgs) {
337
445
  return wrapResponse(res)
338
446
  }
339
447
 
340
- async function handleTap(args: ToolCallArgs) {
448
+ async function executeTap(args: ToolCallArgs) {
341
449
  const platform = getStringArg(args, 'platform') as PlatformArg | undefined
342
450
  const x = requireNumberArg(args, 'x')
343
451
  const y = requireNumberArg(args, 'y')
344
452
  const deviceId = getStringArg(args, 'deviceId')
345
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
453
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
454
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
346
455
  ToolsNetwork.notifyActionStart()
347
- const res = await ToolsInteract.tapHandler({ platform, x, y, deviceId })
348
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
349
- return wrapResponse(buildActionExecutionResult({
456
+ const res = await measure('dispatch_ms', () => ToolsInteract.tapHandler({ platform, x, y, deviceId, timeoutMs: actionTimeoutMs }))
457
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
458
+ const result = buildActionExecutionResult({
350
459
  actionType: 'tap',
351
460
  sourceModule: 'server',
352
461
  selector: { x, y },
@@ -354,17 +463,154 @@ async function handleTap(args: ToolCallArgs) {
354
463
  uiFingerprintBefore,
355
464
  uiFingerprintAfter,
356
465
  failure: res.success ? undefined : inferGenericFailure((res as any).error)
357
- }))
466
+ })
467
+ return wrapResponse(result)
358
468
  }
359
469
 
360
- async function handleTapElement(args: ToolCallArgs) {
361
- const elementId = requireStringArg(args, 'elementId')
470
+ async function executeTapElement(args: ToolCallArgs) {
471
+ const controls = tapElementControlsSchema.safeParse({ elementId: args.elementId, selector: args.selector, waitFor: args.waitFor })
472
+ if (!controls.success) throw new Error(`INVALID_ARGUMENT: ${controls.error.message}`)
473
+ const elementId = controls.data.elementId
474
+ const selector = controls.data.selector as ExpectElementSelectorArg | undefined
475
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
476
+ let resolvedId = elementId
477
+ let freshTree: any
478
+ if (selector) {
479
+ const waitFor = getObjectArg<WaitForUiMatchArg>(args, 'waitFor') as any
480
+ const resolution = await measure('target_resolution_ms', () => runWithBudget(waitFor?.timeoutMs ?? 1500, () => ToolsInteract.waitForUIHandler({ selector, condition: waitFor?.condition ?? 'clickable', timeout_ms: waitFor?.timeoutMs ?? 1500, poll_interval_ms: waitFor?.pollIntervalMs ?? 100, match: waitFor?.match, singleObservation: true, singleAttempt: !waitFor, rejectAmbiguous: !waitFor, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') }))) as any
481
+ if (resolution.status !== 'success' || !resolution.element?.elementId) return wrapResponse({ success: false, action_type: 'tap_element', failure_code: resolution.error?.code ?? 'ELEMENT_NOT_FOUND', retryable: true })
482
+ resolvedId = resolution.element.elementId
483
+ freshTree = resolution._tree
484
+ }
362
485
  ToolsNetwork.notifyActionStart()
363
- const res = await ToolsInteract.tapElementHandler({ elementId })
486
+ const res = await measure('dispatch_ms', () => ToolsInteract.tapElementHandler({ elementId: resolvedId!, freshTree, verificationMode, timeoutMs: actionTimeoutMs, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') })) as any
364
487
  return wrapResponse(res)
365
488
  }
366
489
 
367
- async function handleSwipe(args: ToolCallArgs) {
490
+ function payloadOf(response: ToolCallResult) {
491
+ const text = response.content.find((item: any) => item.type === 'text')?.text
492
+ return text ? JSON.parse(text) : null
493
+ }
494
+
495
+ async function expectElementAbsent({ selector, timeoutMs, pollIntervalMs, platform, deviceId }: { selector: ExpectElementSelectorArg, timeoutMs: number, pollIntervalMs: number, platform: PlatformArg, deviceId?: string }) {
496
+ const deadline = performance.now() + timeoutMs
497
+ let consecutive = 0
498
+ let observed: unknown
499
+ while (performance.now() < deadline) {
500
+ const result = await ToolsInteract.waitForUIHandler({ selector, condition: 'not_exists', timeout_ms: 0, singleObservation: true, singleAttempt: true, platform, deviceId }) as any
501
+ observed = result.observed
502
+ consecutive = result.status === 'success' ? consecutive + 1 : 0
503
+ if (consecutive === 2 && performance.now() <= deadline) return { success: true, observed }
504
+ const delay = Math.max(100, pollIntervalMs)
505
+ if (performance.now() + delay >= deadline) break
506
+ await new Promise(resolve => setTimeout(resolve, delay))
507
+ }
508
+ return { success: false, failure_code: 'ASSERTION_TIMEOUT', observed }
509
+ }
510
+
511
+ async function expectFingerprint({ fingerprint, timeoutMs, platform, deviceId }: { fingerprint: string, timeoutMs: number, platform: PlatformArg, deviceId?: string }) {
512
+ const deadline = performance.now() + timeoutMs
513
+ let consecutive = 0
514
+ let observed: string | null = null
515
+ while (performance.now() < deadline) {
516
+ const result = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as any
517
+ observed = result?.fingerprint ?? null
518
+ consecutive = observed === fingerprint ? consecutive + 1 : 0
519
+ if (consecutive === 2 && performance.now() <= deadline) return { success: true, observed_fingerprint: observed }
520
+ if (performance.now() + 100 >= deadline) break
521
+ await new Promise(resolve => setTimeout(resolve, 100))
522
+ }
523
+ return { success: false, failure_code: 'ASSERTION_TIMEOUT', observed_fingerprint: observed }
524
+ }
525
+
526
+ async function handleRunJourney(args: ToolCallArgs) {
527
+ const parsed = journeySchema.safeParse(args)
528
+ if (!parsed.success) throw new Error(`INVALID_ARGUMENT: ${parsed.error.message}`)
529
+ const { platform, responseMode, defaults, captureOnFailure } = parsed.data
530
+ const steps = parsed.data.steps as Array<Record<string, any>>
531
+ const device = await resolveTargetDevice({ platform, deviceId: parsed.data.deviceId })
532
+ const deviceId = device.id
533
+ return withResolvedDevice(device, async () => {
534
+ const started = performance.now()
535
+ const results: any[] = []
536
+ let stoppedAt: string | null = null
537
+ let pendingLightStep: number | null = null
538
+ for (let index = 0; index < steps.length; index++) {
539
+ const step = steps[index]
540
+ const requestedVerification = step.verificationMode ?? defaults?.verificationMode ?? 'full'
541
+ const next = steps[index + 1]
542
+ const lightCoveredByAssertion = step.type === 'tap' && responseMode === 'compact' && requestedVerification === 'light' && next?.type === 'assert' &&
543
+ next.assertion?.kind === 'element_visible'
544
+ const stepStart = performance.now()
545
+ let result: any
546
+ if (stoppedAt) {
547
+ results.push({ id: step.id, type: step.type, status: 'not_run', success: false, reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 } })
548
+ continue
549
+ }
550
+ try {
551
+ if (step.type === 'tap') {
552
+ result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }))
553
+ } else if (step.type === 'wait') {
554
+ result = await ToolsInteract.waitForUIHandler({ selector: step.selector as ExpectElementSelectorArg, condition: (step.condition as any) ?? 'exists', timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
555
+ result = { success: result.status === 'success', ...result }
556
+ } else if (step.type === 'assert') {
557
+ result = await runWithBudget((step.timeoutMs as number | undefined) ?? 5000, async () => {
558
+ const assertion = step.assertion as any
559
+ if (assertion?.kind === 'element_visible') result = await ToolsInteract.expectElementVisibleHandler({ selector: assertion.selector, timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
560
+ else if (assertion?.kind === 'element_absent') result = await expectElementAbsent({ selector: assertion.selector, timeoutMs: (step.timeoutMs as number | undefined) ?? 5000, pollIntervalMs: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
561
+ else if (assertion?.kind === 'screen_fingerprint') result = await expectFingerprint({ fingerprint: assertion.fingerprint, timeoutMs: (step.timeoutMs as number | undefined) ?? 5000, platform, deviceId })
562
+ else if (assertion?.kind === 'state_equals') result = await ToolsInteract.expectStateHandler({ selector: assertion.selector, element_id: assertion.elementId, property: assertion.property, expected: assertion.expected, platform, deviceId, stabilization_window_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, exact_budget: true })
563
+ else result = { success: false, failure_code: 'INVALID_ASSERTION' }
564
+ return result
565
+ })
566
+ } else if (step.type === 'start_app') {
567
+ result = payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
568
+ } else {
569
+ result = { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' }
570
+ }
571
+ } catch (error) {
572
+ result = { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) }
573
+ }
574
+ if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? '')) result.failure_code = 'ASSERTION_TIMEOUT'
575
+ const success = !!result?.success
576
+ if (lightCoveredByAssertion) {
577
+ result.verification = { mode: 'light', status: success ? 'unavailable' : 'failed' }
578
+ if (success) {
579
+ result.verification_diagnostic = { code: 'VERIFICATION_UNAVAILABLE', retryable: true }
580
+ pendingLightStep = results.length
581
+ }
582
+ }
583
+ if (step.type === 'assert' && pendingLightStep !== null) {
584
+ const pending = results[pendingLightStep].result
585
+ if (typeof result?.observed?.matched_count === 'number') {
586
+ pending.verification.status = 'observed'
587
+ delete pending.verification_diagnostic
588
+ const delta = ToolsObserve.getLatestStateDelta(platform, deviceId)
589
+ if (delta) { pending.state_delta = delta; pending.state_delta_available = true }
590
+ }
591
+ pendingLightStep = null
592
+ }
593
+ if (responseMode === 'compact' && ['wait', 'assert'].includes(step.type)) result = {
594
+ success, ...(result.failure_code ? { failure_code: result.failure_code } : {}),
595
+ ...(result.error?.code ? { failure_code: result.error.code } : {}),
596
+ ...(result.observed?.matched_count !== undefined ? { matched_count: result.observed.matched_count } : {}),
597
+ ...(result.observed_fingerprint !== undefined ? { matched: success } : {}),
598
+ ...(result.actual_state ? { observed: result.actual_state } : {})
599
+ }
600
+ results.push({ id: step.id, type: step.type, status: success ? 'passed' : 'failed', success, result, timing: { total_ms: performance.now() - stepStart } })
601
+ if (!success) stoppedAt = step.id as string
602
+ }
603
+ const failureSnapshot = stoppedAt && captureOnFailure && responseMode === 'debug'
604
+ ? await ToolsObserve.captureDebugSnapshotHandler({ platform, deviceId, includeScreenshot: false }).catch(() => null) : undefined
605
+ const componentTiming: Record<string, number> = {}
606
+ for (const step of results) for (const [key, value] of Object.entries(step.result?.timing ?? {})) {
607
+ if (key !== 'total_ms' && typeof value === 'number') componentTiming[key] = (componentTiming[key] ?? 0) + value
608
+ }
609
+ return wrapResponse({ success: stoppedAt === null, stopped_at_step_id: stoppedAt, steps: results, ...(failureSnapshot ? { failure_snapshot: failureSnapshot } : {}), timing: responseMode === 'compact' ? { total_ms: performance.now() - started } : { ...componentTiming, total_ms: performance.now() - started } }, responseMode === 'debug')
610
+ })
611
+ }
612
+
613
+ async function executeSwipe(args: ToolCallArgs) {
368
614
  const platform = (getStringArg(args, 'platform') as PlatformArg | undefined) ?? 'android'
369
615
  const x1 = requireNumberArg(args, 'x1')
370
616
  const y1 = requireNumberArg(args, 'y1')
@@ -372,11 +618,12 @@ async function handleSwipe(args: ToolCallArgs) {
372
618
  const y2 = requireNumberArg(args, 'y2')
373
619
  const duration = requireNumberArg(args, 'duration')
374
620
  const deviceId = getStringArg(args, 'deviceId')
375
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
621
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
622
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
376
623
  ToolsNetwork.notifyActionStart()
377
- const res = await ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId })
378
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
379
- return wrapResponse(buildActionExecutionResult({
624
+ const res = await measure('dispatch_ms', () => ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId, timeoutMs: actionTimeoutMs }))
625
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
626
+ const result = buildActionExecutionResult({
380
627
  actionType: 'swipe',
381
628
  sourceModule: 'server',
382
629
  selector: { x1, y1, x2, y2, duration },
@@ -384,21 +631,23 @@ async function handleSwipe(args: ToolCallArgs) {
384
631
  uiFingerprintBefore,
385
632
  uiFingerprintAfter,
386
633
  failure: res.success ? undefined : inferGenericFailure((res as any).error)
387
- }))
634
+ })
635
+ return wrapResponse(result)
388
636
  }
389
637
 
390
- async function handleScrollToElement(args: ToolCallArgs) {
638
+ async function executeScrollToElement(args: ToolCallArgs) {
391
639
  const platform = requireStringArg(args, 'platform') as PlatformArg
392
640
  const selector = requireObjectArg<ScrollSelectorArg>(args, 'selector')
393
641
  const direction = getStringArg(args, 'direction') as 'down' | 'up' | undefined
394
642
  const maxScrolls = getNumberArg(args, 'maxScrolls')
395
643
  const scrollAmount = getNumberArg(args, 'scrollAmount')
396
644
  const deviceId = getStringArg(args, 'deviceId')
397
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
645
+ const { verificationMode } = interactionControls(args)
646
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
398
647
  ToolsNetwork.notifyActionStart()
399
- const res = await ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId })
400
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
401
- return wrapResponse(buildActionExecutionResult({
648
+ const res = await measure('dispatch_ms', () => ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId }))
649
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
650
+ const result = buildActionExecutionResult({
402
651
  actionType: 'scroll_to_element',
403
652
  sourceModule: 'server',
404
653
  selector: selector ?? null,
@@ -415,17 +664,19 @@ async function handleScrollToElement(args: ToolCallArgs) {
415
664
  uiFingerprintBefore,
416
665
  uiFingerprintAfter,
417
666
  failure: res.success ? undefined : inferScrollFailure((res as any).reason)
418
- }))
667
+ })
668
+ return wrapResponse(result)
419
669
  }
420
670
 
421
- async function handleTypeText(args: ToolCallArgs) {
671
+ async function executeTypeText(args: ToolCallArgs) {
422
672
  const text = requireStringArg(args, 'text')
423
673
  const deviceId = getStringArg(args, 'deviceId')
424
- const uiFingerprintBefore = await captureActionFingerprint('android', deviceId)
674
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
675
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
425
676
  ToolsNetwork.notifyActionStart()
426
- const res = await ToolsInteract.typeTextHandler({ text, deviceId })
427
- const uiFingerprintAfter = await captureActionFingerprint('android', deviceId)
428
- return wrapResponse(buildActionExecutionResult({
677
+ const res = await measure('dispatch_ms', () => ToolsInteract.typeTextHandler({ text, deviceId, timeoutMs: actionTimeoutMs }))
678
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
679
+ const result = buildActionExecutionResult({
429
680
  actionType: 'type_text',
430
681
  sourceModule: 'server',
431
682
  selector: { text },
@@ -433,16 +684,18 @@ async function handleTypeText(args: ToolCallArgs) {
433
684
  uiFingerprintBefore,
434
685
  uiFingerprintAfter,
435
686
  failure: res.success ? undefined : inferGenericFailure((res as any).error)
436
- }))
687
+ })
688
+ return wrapResponse(result)
437
689
  }
438
690
 
439
- async function handlePressBack(args: ToolCallArgs) {
691
+ async function executePressBack(args: ToolCallArgs) {
440
692
  const deviceId = getStringArg(args, 'deviceId')
441
- const uiFingerprintBefore = await captureActionFingerprint('android', deviceId)
693
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
694
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
442
695
  ToolsNetwork.notifyActionStart()
443
- const res = await ToolsInteract.pressBackHandler({ deviceId })
444
- const uiFingerprintAfter = await captureActionFingerprint('android', deviceId)
445
- return wrapResponse(buildActionExecutionResult({
696
+ const res = await measure('dispatch_ms', () => ToolsInteract.pressBackHandler({ deviceId, timeoutMs: actionTimeoutMs }))
697
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
698
+ const result = buildActionExecutionResult({
446
699
  actionType: 'press_back',
447
700
  sourceModule: 'server',
448
701
  selector: { key: 'back' },
@@ -450,7 +703,8 @@ async function handlePressBack(args: ToolCallArgs) {
450
703
  uiFingerprintBefore,
451
704
  uiFingerprintAfter,
452
705
  failure: res.success ? undefined : inferGenericFailure((res as any).error)
453
- }))
706
+ })
707
+ return wrapResponse(result)
454
708
  }
455
709
 
456
710
  async function handleStartLogStream(args: ToolCallArgs) {
@@ -528,6 +782,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
528
782
  find_element: handleFindElement,
529
783
  tap: handleTap,
530
784
  tap_element: handleTapElement,
785
+ run_journey: handleRunJourney,
531
786
  swipe: handleSwipe,
532
787
  scroll_to_element: handleScrollToElement,
533
788
  type_text: handleTypeText,
@@ -543,10 +798,26 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}) {
543
798
  const handler = toolHandlers[name]
544
799
  if (!handler) throw new Error(`Unknown tool: ${name}`)
545
800
 
546
- try {
547
- return await handler(args)
548
- } catch (error) {
549
- console.error(`Error executing tool ${name}:`, error)
550
- return wrapToolError(name, error)
801
+ const started = performance.now()
802
+ const measured: Record<string, number> = {}
803
+ const addTiming = (response: ToolCallResult) => {
804
+ if (!['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'wait_for_ui', 'get_ui_tree', 'capture_screenshot', 'capture_debug_snapshot'].includes(name)) return response
805
+ const item = response.content.find(item => item.type === 'text')
806
+ if (item?.text) {
807
+ try {
808
+ const value = JSON.parse(item.text)
809
+ value.timing = { ...measured, ...value.timing, total_ms: performance.now() - started }
810
+ item.text = JSON.stringify(value)
811
+ } catch { /* Non-JSON text responses retain their original content. */ }
812
+ }
813
+ return response
551
814
  }
815
+ return withTiming(measured, async () => {
816
+ try {
817
+ return addTiming(await handler(args))
818
+ } catch (error) {
819
+ console.error(`Error executing tool ${name}:`, error)
820
+ return addTiming(wrapToolError(name, error))
821
+ }
822
+ })
552
823
  }
@@ -16,7 +16,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
16
16
 
17
17
  export const serverInfo = {
18
18
  name: 'mobile-debug-mcp',
19
- version: '0.31.0'
19
+ version: '0.32.0'
20
20
  }
21
21
 
22
22
  export function createServer() {
package/src/types.ts CHANGED
@@ -162,6 +162,16 @@ export interface SnapshotDelta {
162
162
  total_elements: number;
163
163
  }
164
164
 
165
+ export interface StateDelta {
166
+ base_snapshot_revision: number | null;
167
+ snapshot_revision: number;
168
+ added: UIElement[];
169
+ removed: Array<{ stable_id?: string; resourceId?: string; contentDescription?: string; text?: string; index: number }>;
170
+ changed: UIElement[];
171
+ truncated: boolean;
172
+ omitted_changes?: number;
173
+ }
174
+
165
175
  export interface CaptureAndroidScreenResponse {
166
176
  device: DeviceInfo;
167
177
  screenshot: string; // base64 encoded string
@@ -282,6 +292,8 @@ export type ActionFailureCode =
282
292
  | 'ELEMENT_NOT_FOUND'
283
293
  | 'ELEMENT_NOT_INTERACTABLE'
284
294
  | 'TIMEOUT'
295
+ | 'ACTION_TIMEOUT'
296
+ | 'ASSERTION_TIMEOUT'
285
297
  | 'NAVIGATION_NO_CHANGE'
286
298
  | 'AMBIGUOUS_TARGET'
287
299
  | 'STALE_REFERENCE'
@@ -393,11 +405,23 @@ export interface FindElementResponse {
393
405
  error?: string;
394
406
  }
395
407
 
408
+ export interface TimingBreakdown {
409
+ total_ms: number;
410
+ device_resolution_ms?: number;
411
+ pre_observation_ms?: number;
412
+ target_resolution_ms?: number;
413
+ dispatch_ms?: number;
414
+ post_observation_ms?: number;
415
+ verification_ms?: number;
416
+ screenshot_ms?: number;
417
+ serialization_ms?: number;
418
+ }
419
+
396
420
  export interface ActionExecutionResult {
397
421
  action_id: string;
398
422
  timestamp: string;
399
423
  action_type: string;
400
- lifecycle_state?: 'pending_verification' | 'failed';
424
+ lifecycle_state?: 'pending_verification' | 'verified' | 'failed';
401
425
  source_module?: 'server' | 'interact';
402
426
  device?: DeviceInfo;
403
427
  target: {
@@ -407,6 +431,11 @@ export interface ActionExecutionResult {
407
431
  success: boolean;
408
432
  failure_code?: ActionFailureCode;
409
433
  retryable?: boolean;
434
+ delivery_status?: 'delivered' | 'not_delivered' | 'unknown';
435
+ dispatch_started?: boolean;
436
+ verification?: { mode: 'none' | 'light' | 'full'; status: 'not_requested' | 'observed' | 'verified' | 'unavailable' | 'failed' };
437
+ state_delta?: StateDelta;
438
+ timing?: TimingBreakdown;
410
439
  recovery?: RecoveryState;
411
440
  trace: ActionTrace;
412
441
  ui_fingerprint_before?: string | null;
@@ -414,7 +443,7 @@ export interface ActionExecutionResult {
414
443
  details?: Record<string, unknown>;
415
444
  }
416
445
 
417
- export interface TapElementResponse extends ActionExecutionResult {}
446
+ export interface TapElementResponse extends ActionExecutionResult { error?: string }
418
447
 
419
448
  export interface ExpectScreenResponse {
420
449
  success: boolean;
package/src/utils/exec.ts CHANGED
@@ -1,9 +1,11 @@
1
+ import { remainingBudget } from './operation-budget.js'
1
2
  import { spawn } from 'child_process'
2
3
 
3
4
  export type ExecOptions = { timeout?: number; env?: NodeJS.ProcessEnv; cwd?: string; shell?: boolean }
4
5
 
5
6
  export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {}): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
6
- const { timeout = 0, env, cwd, shell } = opts
7
+ const { env, cwd, shell } = opts
8
+ const timeout = remainingBudget(opts.timeout ?? 0)
7
9
  return new Promise((resolve, reject) => {
8
10
  const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell })
9
11
  let stdout = ''
@@ -15,7 +17,7 @@ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {
15
17
  const timer = timeout && timeout > 0 ? setTimeout(() => {
16
18
  timedOut = true
17
19
  try { child.kill() } catch { }
18
- resolve({ exitCode: null, stdout: stdout.trim(), stderr: stderr.trim() })
20
+ resolve({ exitCode: null, stdout: stdout.trim(), stderr: 'ACTION_TIMEOUT' })
19
21
  }, timeout) : null
20
22
 
21
23
  child.on('close', (code) => {