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
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 = ''
@@ -12,22 +14,28 @@ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {
12
14
  if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString() })
13
15
 
14
16
  let timedOut = false
17
+ let forceKillTimer: NodeJS.Timeout | null = null
15
18
  const timer = timeout && timeout > 0 ? setTimeout(() => {
16
19
  timedOut = true
17
20
  try { child.kill() } catch { }
18
- resolve({ exitCode: null, stdout: stdout.trim(), stderr: stderr.trim() })
21
+ forceKillTimer = setTimeout(() => {
22
+ try { child.kill('SIGKILL') } catch { }
23
+ }, 1000)
24
+ forceKillTimer.unref()
19
25
  }, timeout) : null
20
26
 
21
27
  child.on('close', (code) => {
22
28
  if (timer) clearTimeout(timer)
23
- if (timedOut) return
24
- resolve({ exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() })
29
+ if (forceKillTimer) clearTimeout(forceKillTimer)
30
+ resolve(timedOut
31
+ ? { exitCode: null, stdout: stdout.trim(), stderr: 'ACTION_TIMEOUT' }
32
+ : { exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() })
25
33
  })
26
34
 
27
35
  child.on('error', (err) => {
28
36
  if (timer) clearTimeout(timer)
37
+ if (forceKillTimer) clearTimeout(forceKillTimer)
29
38
  reject(err)
30
39
  })
31
40
  })
32
41
  }
33
-
@@ -1,3 +1,4 @@
1
+ import { remainingBudget } from '../operation-budget.js'
1
2
  import { execFile, spawn, execSync, spawnSync } from "child_process"
2
3
  import { DeviceInfo } from "../../types.js"
3
4
  import { promises as fsPromises } from 'fs'
@@ -86,6 +87,7 @@ export function validateBundleId(bundleId: string) {
86
87
  export function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
87
88
  return new Promise((resolve, reject) => {
88
89
  // Use spawn for better stream control and consistency with Android implementation
90
+ const budgetMs = remainingBudget()
89
91
  const child = spawn(getXcrunCmd(), args)
90
92
 
91
93
  let stdout = ''
@@ -105,7 +107,7 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
105
107
 
106
108
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000 // env (ms) or default 30s
107
109
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000 // env (ms) or default 60s
108
- const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT // choose appropriate timeout
110
+ const timeoutMs = budgetMs || (args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT) // choose appropriate timeout
109
111
  const timeout = setTimeout(() => {
110
112
  child.kill()
111
113
  reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`))
@@ -128,10 +130,11 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
128
130
  }
129
131
 
130
132
  export function execCommandWithDiagnostics(args: string[], deviceId: string = "booted") {
133
+ const budgetMs = remainingBudget()
131
134
  // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
132
135
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
133
136
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
134
- const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT
137
+ const timeoutMs = budgetMs || (args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT)
135
138
  const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs }) as any
136
139
  const runResult = {
137
140
  exitCode: typeof res.status === 'number' ? res.status : null,
@@ -0,0 +1,22 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ export interface OperationBudget { deadline: number; dispatchStarted: boolean; parent?: OperationBudget }
4
+ const budgets = new AsyncLocalStorage<OperationBudget>()
5
+ export function hasFiniteBudget() { return Number.isFinite(budgets.getStore()?.deadline) }
6
+ export function runWithBudget<T>(timeoutMs: number, operation: (budget: OperationBudget) => Promise<T>) {
7
+ const parent = budgets.getStore()
8
+ const budget = { deadline: Math.min(parent?.deadline ?? Infinity, performance.now() + timeoutMs), dispatchStarted: false, parent }
9
+ return budgets.run(budget, () => operation(budget))
10
+ }
11
+ export function remainingBudget(fallback = 0): number {
12
+ const budget = budgets.getStore()
13
+ if (!budget || !Number.isFinite(budget.deadline)) return fallback
14
+ const remaining = Math.floor(budget.deadline - performance.now())
15
+ if (remaining <= 0) throw new Error('ACTION_TIMEOUT')
16
+ return fallback > 0 ? Math.min(fallback, remaining) : remaining
17
+ }
18
+ export function markDispatchStarted() {
19
+ remainingBudget()
20
+ let budget = budgets.getStore()
21
+ while (budget) { budget.dispatchStarted = true; budget = budget.parent }
22
+ }
@@ -1,3 +1,6 @@
1
+ import { retainConnectedDeviceSnapshots } from '../observe/snapshot-metadata.js'
2
+ import { measure } from './timing.js'
3
+ import { AsyncLocalStorage } from 'node:async_hooks'
1
4
  import { DeviceInfo } from "../types.js"
2
5
  import { listAndroidDevices } from "./android/utils.js"
3
6
  import { listIOSDevices } from "./ios/utils.js"
@@ -44,9 +47,18 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
44
47
  return iosDeviceLister(appId)
45
48
  }
46
49
 
50
+ const deviceContext = new AsyncLocalStorage<DeviceInfo>()
51
+
52
+ export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promise<T>): Promise<T> {
53
+ return deviceContext.run(device, operation)
54
+ }
55
+
47
56
  export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceInfo> {
48
57
  const { platform, appId, prefer, deviceId } = opts
49
- const devices = await listDevices(platform, appId)
58
+ const current = deviceContext.getStore()
59
+ if (current && current.platform === platform && (!deviceId || current.id === deviceId)) return current
60
+ const devices = await measure('device_resolution_ms', () => listDevices(platform, appId))
61
+ retainConnectedDeviceSnapshots(platform, devices.map(device => device.id))
50
62
 
51
63
  // During unit tests (no adb/xcrun available), provide a lightweight mock device so
52
64
  // the observe/interact unit tests can run without real devices.
@@ -0,0 +1,15 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ const timings = new AsyncLocalStorage<Record<string, number>>()
3
+ export function withTiming<T>(values: Record<string, number>, operation: () => Promise<T>) { return timings.run(values, operation) }
4
+ export function recordTiming(component: string, milliseconds: number) {
5
+ const values = timings.getStore()
6
+ if (values) values[component] = (values[component] ?? 0) + milliseconds
7
+ }
8
+ export async function measure<T>(component: string, operation: () => Promise<T>): Promise<T> {
9
+ const start = performance.now()
10
+ try { return await operation() }
11
+ finally {
12
+ const values = timings.getStore()
13
+ if (values) values[component] = (values[component] ?? 0) + performance.now() - start
14
+ }
15
+ }
@@ -1,3 +1,4 @@
1
+ import { remainingBudget } from '../operation-budget.js'
1
2
  import crypto from 'crypto'
2
3
  import { GetUITreeResponse, GetCurrentScreenResponse, UIElement, SwipeResponse } from '../../types.js'
3
4
 
@@ -152,6 +153,7 @@ export async function scrollToElementShared(opts: {
152
153
  let scrollsPerformed = 0
153
154
 
154
155
  for (let i = 0; i < maxScrolls; i++) {
156
+ remainingBudget()
155
157
  const { x1, y1, x2, y2 } = computeCoords()
156
158
  try {
157
159
  await swipe(x1, y1, x2, y2, duration, deviceId)
@@ -0,0 +1,136 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
6
+ import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
7
+ import { summarize } from './report.js'
8
+
9
+ const appId = 'dev.mobiledebugmcp.latencyfixture'
10
+ const stepTimeoutMs = 30000
11
+ const targetWaitTimeoutMs = 10000
12
+ const verificationTimeoutMs = 60000
13
+ const deviceId = process.env.LATENCY_DEVICE_ID
14
+ if (!deviceId) throw new Error('Set LATENCY_DEVICE_ID to the named Android emulator serial')
15
+ const adb = (...args: string[]) => execFileSync('adb', ['-s', deviceId, ...args], { encoding: 'utf8', timeout: 30000 }).trim()
16
+ const apk = buildFixture()
17
+ const selectorMap = {
18
+ savedSession: 'saved-session', idleDetail: 'idle-detail', activeDetail: 'active-detail',
19
+ activate: 'activate', deactivate: 'deactivate', home: 'home', homeScreen: 'home-screen',
20
+ stop: 'stop', stopped: 'stopped'
21
+ }
22
+ const selector = (id: string) => ({ accessibility_id: id })
23
+ const flow = [
24
+ [selectorMap.savedSession, selectorMap.idleDetail], [selectorMap.activate, selectorMap.activeDetail],
25
+ [selectorMap.home, selectorMap.homeScreen], [selectorMap.savedSession, selectorMap.activeDetail],
26
+ [selectorMap.deactivate, selectorMap.idleDetail], [selectorMap.stop, selectorMap.stopped]
27
+ ]
28
+ const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
29
+ transport.stderr?.on('data', chunk => process.stderr.write(`[server] ${chunk.toString()}`))
30
+ const client = new Client({ name: 'latency-benchmark', version: '1.0' })
31
+ const raw: any[] = []
32
+ const requestSequence: Record<string, any[]> = {}
33
+ let sequence: any[] = []
34
+ let failedSelector: string | null = null
35
+ let failedTool: string | null = null
36
+ async function call(name: string, args: any) {
37
+ failedTool = name
38
+ sequence.push({ tool: name, ...args, appId: args.appId ? '[fixture]' : undefined, deviceId: undefined, steps: args.steps?.map((step: any) => ({ ...step, appId: step.appId ? '[fixture]' : undefined })) })
39
+ const start = performance.now()
40
+ let response
41
+ try {
42
+ response = await client.callTool({ name, arguments: { platform: 'android', deviceId, ...args } }, undefined, { timeout: 300_000 })
43
+ } catch (error) {
44
+ console.error(`MCP ${name} rejected: ${error instanceof Error ? error.stack : String(error)}`)
45
+ throw error
46
+ }
47
+ const elapsed = performance.now() - start
48
+ const text = (response.content as any[]).find(item => item.type === 'text')?.text
49
+ let data: any
50
+ try { data = JSON.parse(text) } catch { data = {} }
51
+ if (response.isError || data.success === false || data.status === 'timeout' || data.error) {
52
+ const failed = args.steps?.find((step: any) => step.id === data.stopped_at_step_id)
53
+ if (failed) { failedSelector = failed.selector?.accessibility_id ?? failed.assertion?.selector?.accessibility_id ?? null; failedTool = `run_journey:${failed.id}` }
54
+ throw new Error('Fixture step failed')
55
+ }
56
+ return { data, elapsed }
57
+ }
58
+ async function run(mode: string) {
59
+ sequence = []
60
+ failedSelector = null
61
+ failedTool = null
62
+ adb('shell', 'am', 'force-stop', appId)
63
+ try { adb('shell', 'pm', 'clear', appId) } catch { /* First install has no app data. */ }
64
+ adb('install', '-r', apk)
65
+ // Check the reset state outside the measured MCP flow, then force-stop once more.
66
+ failedTool = 'reset-state'
67
+ failedSelector = selectorMap.savedSession
68
+ adb('shell', 'am', 'start', '-n', `${appId}/.MainActivity`)
69
+ adb('shell', 'uiautomator', 'dump', '/sdcard/ui.xml')
70
+ if (!adb('shell', 'cat', '/sdcard/ui.xml').includes(`content-desc="${selectorMap.savedSession}"`)) throw new Error('Reset selector missing')
71
+ adb('shell', 'am', 'force-stop', appId)
72
+ sequence = []
73
+ const requestTimes: number[] = []
74
+ const componentTimes: Record<string, number[]> = {}
75
+ const record = (response: any) => {
76
+ requestTimes.push(response.elapsed)
77
+ const timing = response.data.timing ?? {}
78
+ const components = Object.entries(timing).filter(([key, value]) => key !== 'total_ms' && typeof value === 'number')
79
+ const stepTimings = (response.data.steps ?? []).flatMap((step: any) => Object.entries(step.result?.timing ?? {}))
80
+ const selected = components.length ? components : stepTimings
81
+ for (const [key, value] of selected) if (typeof value === 'number') (componentTimes[key] ??= []).push(value)
82
+ }
83
+ if (mode === 'baseline') {
84
+ record(await call('start_app', { appId, actionTimeoutMs: stepTimeoutMs, verificationTimeoutMs }))
85
+ for (const [target, expected] of flow) {
86
+ failedSelector = target
87
+ const found = await call('wait_for_ui', { selector: selector(target), condition: 'clickable', timeout_ms: targetWaitTimeoutMs, poll_interval_ms: 100 })
88
+ record(found)
89
+ record(await call('tap_element', { elementId: found.data.element.elementId, actionTimeoutMs: stepTimeoutMs, verificationTimeoutMs }))
90
+ failedSelector = expected
91
+ record(await call('expect_element_visible', { selector: selector(expected), timeout_ms: stepTimeoutMs, poll_interval_ms: 100 }))
92
+ }
93
+ } else {
94
+ const steps: any[] = [{ id: 'launch', type: 'start_app', appId }]
95
+ flow.forEach(([target, expected], index) => {
96
+ steps.push({ id: `tap-${index}`, type: 'tap', selector: selector(target), waitFor: { condition: 'clickable', timeoutMs: targetWaitTimeoutMs, pollIntervalMs: 100 } })
97
+ steps.push({ id: `assert-${index}`, type: 'assert', assertion: { kind: 'element_visible', selector: selector(expected) }, timeoutMs: stepTimeoutMs, pollIntervalMs: 100 })
98
+ })
99
+ record(await call('run_journey', { defaults: { verificationMode: mode === 'full' ? 'full' : 'light', actionTimeoutMs: stepTimeoutMs, verificationTimeoutMs }, responseMode: mode === 'full' ? 'debug' : 'compact', steps }))
100
+ }
101
+ requestSequence[mode] ??= sequence
102
+ const screenshot = await call('capture_screenshot', {})
103
+ const snapshot = await call('capture_debug_snapshot', { includeScreenshot: false, includeLogs: false })
104
+ return { total_ms: requestTimes.reduce((a, b) => a + b, 0), componentTimes, screenshot_ms: screenshot.elapsed, snapshot_ms: snapshot.elapsed }
105
+ }
106
+ async function main() {
107
+ await client.connect(transport)
108
+ try {
109
+ for (const mode of ['baseline', 'full', 'light']) {
110
+ for (let iteration = 0; iteration < 13; iteration++) {
111
+ console.log(`${mode} ${iteration + 1}/13`)
112
+ try { raw.push({ mode, warmup: iteration < 3, success: true, ...await run(mode) }) }
113
+ catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`${mode} run failed at ${failedTool}/${failedSelector}: ${message}`); raw.push({ mode, warmup: iteration < 3, success: false, failed_selector: failedSelector, failed_tool: failedTool, error: message }) }
114
+ }
115
+ }
116
+ } finally { await client.close() }
117
+ const modes = Object.fromEntries(['baseline', 'full', 'light'].map(mode => {
118
+ const samples = raw.filter(item => item.mode === mode && !item.warmup && item.success)
119
+ const failures = raw.filter(item => item.mode === mode && !item.success)
120
+ return [mode, { failures: failures.length, first_failure: failures[0] ? { selector: failures[0].failed_selector, tool_or_step: failures[0].failed_tool } : null, total_ms: summarize(samples.map(item => item.total_ms)), screenshot_ms: summarize(samples.map(item => item.screenshot_ms)), snapshot_ms: summarize(samples.map(item => item.snapshot_ms)), components: Object.fromEntries([...new Set(samples.flatMap(item => Object.keys(item.componentTimes)))].map(key => [key, summarize(samples.flatMap(item => item.componentTimes[key] ?? []))])) }]
121
+ })) as any
122
+ const report = {
123
+ commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), working_tree: 'candidate',
124
+ server_version: JSON.parse(readFileSync('package.json', 'utf8')).version,
125
+ platform: 'android', model: adb('shell', 'getprop', 'ro.product.model'), os: adb('shell', 'getprop', 'ro.build.version.release'), api: adb('shell', 'getprop', 'ro.build.version.sdk'), emulator_image: adb('emu', 'avd', 'name').split('\n')[0].trim(), display_size: adb('shell', 'wm', 'size'), display_density: adb('shell', 'wm', 'density'), adb_timeout_ms: Number(process.env.MCP_ADB_TIMEOUT ?? 20000),
126
+ fixture_version: '1.0', fixture_sha256: createHash('sha256').update(readFileSync(apk)).digest('hex'), warmup_runs: 3, measured_runs: 10,
127
+ modes, requests: requestSequence, raw,
128
+ gates: { full_p95: !!modes.baseline.total_ms && !!modes.full.total_ms && modes.full.total_ms.p95 <= modes.baseline.total_ms.p95 * 1.1, light_p50: !!modes.full.total_ms && !!modes.light.total_ms && modes.light.total_ms.p50 <= modes.full.total_ms.p50 * .7 }
129
+ }
130
+ const root = 'docs/benchmarks/interaction-latency'
131
+ mkdirSync(root, { recursive: true })
132
+ writeFileSync(`${root}/final.json`, JSON.stringify(report, null, 2))
133
+ writeFileSync(`${root}/final.md`, `# Interaction latency benchmark\n\nCommit: ${report.commit} (working tree candidate). Fixture: ${report.fixture_version}, SHA-256 ${report.fixture_sha256}.\n\nEnvironment: ${report.emulator_image}, Android ${report.os}, API ${report.api}. Three warm-ups and ten measured runs per mode. Failed runs excluded.\n\n| Mode | p50 ms | p95 ms | Failures |\n|---|---:|---:|---:|\n${Object.entries(modes).map(([mode, value]: [string, any]) => `| ${mode} | ${value.total_ms?.p50 ?? 'n/a'} | ${value.total_ms?.p95 ?? 'n/a'} | ${value.failures} |`).join('\n')}\n\nFirst failure by mode: ${Object.entries(modes).map(([mode, value]: [string, any]) => `${mode}: ${value.first_failure ? `${value.first_failure.tool_or_step} / ${value.first_failure.selector ?? 'no selector'}` : 'none'}`).join('; ')}.\n\nFull p95 gate: ${report.gates.full_p95}. Light p50 gate: ${report.gates.light_p50}.\n\nSee final.json for requests, timing components, and failure selectors.\n`)
134
+ if (!report.gates.full_p95 || !report.gates.light_p50) process.exitCode = 1
135
+ }
136
+ main().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,6 @@
1
+ export function summarize(samples: number[]) {
2
+ if (!samples.length) return null
3
+ const sorted = [...samples].sort((a, b) => a - b)
4
+ const percentile = (p: number) => sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)]
5
+ return { count: samples.length, p50: percentile(.5), p95: percentile(.95), min: sorted[0], max: sorted[sorted.length - 1] }
6
+ }