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
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;
@@ -20,8 +20,10 @@ function findInPath(cmd: string): string | null {
20
20
  }
21
21
 
22
22
  export function resolveAdbCmd(): string {
23
- // Priority: explicit env ADB_PATH -> ANDROID_SDK_ROOT/platform-tools/adb -> ANDROID_HOME/platform-tools/adb -> ~/Library/Android/sdk/platform-tools/adb -> PATH discovery -> 'adb'
23
+ // Priority: explicit env ADB_PATH -> PATH -> known SDK locations -> fallback to 'adb'
24
24
  if (process.env.ADB_PATH && process.env.ADB_PATH.trim()) return process.env.ADB_PATH
25
+ const found = findInPath('adb')
26
+ if (found) return found
25
27
  const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME
26
28
  if (sdkRoot) {
27
29
  const candidate = path.join(sdkRoot, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb')
@@ -30,8 +32,6 @@ export function resolveAdbCmd(): string {
30
32
  // common macOS user SDK path
31
33
  const homeSdk = path.join(process.env.HOME || '', 'Library', 'Android', 'sdk', 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb')
32
34
  if (existsSync(homeSdk)) return homeSdk
33
- const found = findInPath('adb')
34
- if (found) return found
35
35
  return 'adb'
36
36
  }
37
37
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync, spawnSync } from 'child_process'
3
3
  import { main as installMain } from './install-idb.js'
4
- import { getIdbCmd, isIDBInstalled } from './idb-helper.js'
4
+ import { getIdbCmd, getIdbPackageVersion, isIDBInstalled } from './idb-helper.js'
5
5
 
6
6
  function which(cmd: string): string | null {
7
7
  try {
@@ -48,7 +48,16 @@ try {
48
48
  const idb = process.env.IDB_PATH || getIdbCmd()
49
49
  print('idb:', idb)
50
50
  if (idb && isIDBInstalled()) {
51
- try { print('idb --version:', execSync(`${idb} --version`, { stdio: ['ignore','pipe','ignore'] }).toString().trim()) } catch (e) { print('idb --version: (failed)', e instanceof Error ? e.message : String(e)) }
51
+ const version = getIdbPackageVersion()
52
+ if (version) print('fb-idb version:', version)
53
+ try {
54
+ const output = execSync(`${idb} list-targets --json`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 }).toString().trim()
55
+ print('idb list-targets --json: ok')
56
+ if (output) print(output)
57
+ } catch (e) {
58
+ print('idb list-targets --json: (failed)', e instanceof Error ? e.message : String(e))
59
+ process.exit(2)
60
+ }
52
61
  const companion = which('idb_companion')
53
62
  print('which idb_companion:', companion)
54
63
  if (companion) try { print('idb_companion --version:', execSync('idb_companion --version', { stdio: ['ignore','pipe','ignore'] }).toString().trim()) } catch (e) { print('idb_companion --version: (failed)', e instanceof Error ? e.message : String(e)) }
@@ -59,17 +59,54 @@ export function getIdbCmd(): string | null {
59
59
  return null
60
60
  }
61
61
 
62
- export function isIDBInstalled(): boolean {
63
- const cmd = getIdbCmd()
64
- if (!cmd) return false
65
- try {
66
- // command -v <cmd>
67
- const r = spawnSync('command', ['-v', cmd], { stdio: ['ignore', 'pipe', 'ignore'] })
68
- if (r && r.status === 0) return true
69
- } catch {}
62
+ export function getIdbPackageVersion(): string | null {
63
+ const runners = ['python3', 'python']
64
+ for (const runner of runners) {
65
+ try {
66
+ const result = spawnSync(runner, ['-m', 'pip', 'show', 'fb-idb'], {
67
+ encoding: 'utf8',
68
+ stdio: ['ignore', 'pipe', 'pipe']
69
+ })
70
+ if (result.status !== 0) continue
71
+ const output = `${result.stdout || ''}\n${result.stderr || ''}`
72
+ const match = output.match(/^Version:\s*(.+)$/mi)
73
+ if (match?.[1]) return match[1].trim()
74
+ } catch {
75
+ continue
76
+ }
77
+ }
78
+ return null
79
+ }
80
+
81
+ export function probeIdb(cmd = getIdbCmd()): { ok: boolean; raw: string | null; parsedVersion: string | null } {
82
+ if (!cmd) return { ok: false, raw: null, parsedVersion: null }
83
+
70
84
  try {
71
- execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 })
72
- return true
73
- } catch {}
74
- return false
85
+ const result = spawnSync(cmd, ['list-targets', '--json'], {
86
+ encoding: 'utf8',
87
+ stdio: ['ignore', 'pipe', 'pipe'],
88
+ timeout: 5000
89
+ })
90
+ if (result.status !== 0) {
91
+ return {
92
+ ok: false,
93
+ raw: `${result.stdout || ''}${result.stderr || ''}`.trim() || null,
94
+ parsedVersion: null
95
+ }
96
+ }
97
+
98
+ const version = getIdbPackageVersion()
99
+ const raw = version ? `fb-idb ${version}` : `${result.stdout || ''}${result.stderr || ''}`.trim() || null
100
+ return {
101
+ ok: true,
102
+ raw,
103
+ parsedVersion: version
104
+ }
105
+ } catch {
106
+ return { ok: false, raw: null, parsedVersion: null }
107
+ }
108
+ }
109
+
110
+ export function isIDBInstalled(): boolean {
111
+ return probeIdb().ok
75
112
  }
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) => {
@@ -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'
@@ -59,17 +60,13 @@ export function getIdbCmd() {
59
60
 
60
61
  export async function isIDBInstalled(): Promise<boolean> {
61
62
  const cmd = getIdbCmd()
63
+ if (!cmd) return false
62
64
  try {
63
- execSync(`command -v ${cmd}`, { stdio: ['ignore','pipe','ignore'] })
65
+ execSync(`${cmd} list-targets --json`, { stdio: ['ignore','pipe','pipe'], timeout: 5000 })
64
66
  return true
65
67
  } catch (e: unknown) {
66
- try {
67
- execSync(`${cmd} list-targets --json`, { stdio: ['ignore','pipe','ignore'], timeout: 2000 })
68
- return true
69
- } catch (e2: unknown) {
70
- console.debug(`[isIDBInstalled] idb presence check failed for '${cmd}': ${e instanceof Error ? e.message : String(e2)}`)
71
- return false
72
- }
68
+ console.debug(`[isIDBInstalled] idb probe failed for '${cmd}': ${e instanceof Error ? e.message : String(e)}`)
69
+ return false
73
70
  }
74
71
  }
75
72
 
@@ -90,6 +87,7 @@ export function validateBundleId(bundleId: string) {
90
87
  export function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
91
88
  return new Promise((resolve, reject) => {
92
89
  // Use spawn for better stream control and consistency with Android implementation
90
+ const budgetMs = remainingBudget()
93
91
  const child = spawn(getXcrunCmd(), args)
94
92
 
95
93
  let stdout = ''
@@ -109,7 +107,7 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
109
107
 
110
108
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000 // env (ms) or default 30s
111
109
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000 // env (ms) or default 60s
112
- 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
113
111
  const timeout = setTimeout(() => {
114
112
  child.kill()
115
113
  reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`))
@@ -132,10 +130,11 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
132
130
  }
133
131
 
134
132
  export function execCommandWithDiagnostics(args: string[], deviceId: string = "booted") {
133
+ const budgetMs = remainingBudget()
135
134
  // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
136
135
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
137
136
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
138
- 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)
139
138
  const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs }) as any
140
139
  const runResult = {
141
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
+ }
@@ -0,0 +1,6 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="dev.mobiledebugmcp.latencyfixture" android:versionCode="1" android:versionName="1.0">
2
+ <uses-sdk android:minSdkVersion="23" android:targetSdkVersion="35"/>
3
+ <application android:theme="@android:style/Theme.Material.Light.NoActionBar" android:label="Latency fixture" android:allowBackup="false">
4
+ <activity android:name=".MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity>
5
+ </application>
6
+ </manifest>
@@ -0,0 +1,10 @@
1
+ # Interaction latency fixture
2
+
3
+ An offline native Android fixture, version 1.0. It requires Android SDK platform 36,
4
+ build-tools 36.0.0 and JDK 21. The harness builds and signs it using those installed
5
+ tools; no Gradle dependency download is needed.
6
+
7
+ Selectors are accessibility IDs: `saved-session`, `idle-detail`, `activate`,
8
+ `active-detail`, `home`, `home-screen`, `deactivate`, `stop`, and `stopped`.
9
+ The app keeps control state only in memory. Clearing data and relaunching restores
10
+ Home with an inactive control. It requests no permissions and has no network code.
@@ -0,0 +1,26 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { mkdirSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ export function buildFixture() {
7
+ const root = path.dirname(fileURLToPath(import.meta.url))
8
+ const out = path.join(root, 'build')
9
+ const sdk = process.env.ANDROID_HOME ?? path.join(process.env.HOME!, 'Library/Android/sdk')
10
+ const buildTools = path.join(sdk, 'build-tools', '36.0.0')
11
+ const androidJar = path.join(sdk, 'platforms', 'android-36', 'android.jar')
12
+ const java = process.env.JAVA_HOME
13
+ const tool = (name: string) => java ? path.join(java, 'bin', name) : name
14
+ const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { cwd: out, stdio: 'pipe' })
15
+ mkdirSync(path.join(out, 'classes'), { recursive: true })
16
+ run(tool('javac'), ['--release', '8', '-classpath', androidJar, '-d', 'classes', path.join(root, 'src/MainActivity.java')])
17
+ run(tool('jar'), ['cf', 'classes.jar', '-C', 'classes', '.'])
18
+ run(path.join(buildTools, 'd8'), ['--lib', androidJar, '--output', out, 'classes.jar'])
19
+ run(path.join(buildTools, 'aapt2'), ['link', '-I', androidJar, '--manifest', path.join(root, 'AndroidManifest.xml'), '-o', 'unsigned.apk'])
20
+ run('zip', ['-q', 'unsigned.apk', 'classes.dex'])
21
+ run(path.join(buildTools, 'zipalign'), ['-f', '4', 'unsigned.apk', 'aligned.apk'])
22
+ // Ephemeral signing material is generated outside version control.
23
+ try { run(tool('keytool'), ['-genkeypair', '-keystore', 'fixture.jks', '-storepass', 'android', '-keypass', 'android', '-alias', 'fixture', '-dname', 'CN=Latency Fixture', '-keyalg', 'RSA', '-validity', '3650']) } catch { /* Existing local fixture key. */ }
24
+ run(path.join(buildTools, 'apksigner'), ['sign', '--ks', 'fixture.jks', '--ks-pass', 'pass:android', '--out', 'fixture.apk', 'aligned.apk'])
25
+ return path.join(out, 'fixture.apk')
26
+ }
@@ -0,0 +1,48 @@
1
+ package dev.mobiledebugmcp.latencyfixture;
2
+
3
+ import android.app.Activity;
4
+ import android.os.Bundle;
5
+ import android.widget.Button;
6
+ import android.widget.LinearLayout;
7
+ import android.widget.TextView;
8
+
9
+ public final class MainActivity extends Activity {
10
+ private static final class Selectors {
11
+ static final String SAVED_SESSION = "saved-session";
12
+ static final String IDLE_DETAIL = "idle-detail";
13
+ static final String ACTIVE_DETAIL = "active-detail";
14
+ static final String ACTIVATE = "activate";
15
+ static final String DEACTIVATE = "deactivate";
16
+ static final String HOME = "home";
17
+ static final String HOME_SCREEN = "home-screen";
18
+ static final String STOP = "stop";
19
+ static final String STOPPED = "stopped";
20
+ }
21
+ private boolean active = false;
22
+ private LinearLayout layout;
23
+ @Override public void onCreate(Bundle state) { super.onCreate(state); home(); }
24
+ private void screen(String id) {
25
+ layout = new LinearLayout(this);
26
+ layout.setOrientation(LinearLayout.VERTICAL);
27
+ layout.setPadding(32, 100, 32, 32);
28
+ setContentView(layout);
29
+ TextView title = new TextView(this);
30
+ title.setText(id);
31
+ title.setContentDescription(id);
32
+ layout.addView(title);
33
+ }
34
+ private void button(String id, Runnable action) {
35
+ Button button = new Button(this);
36
+ button.setText(id);
37
+ button.setContentDescription(id);
38
+ button.setOnClickListener(v -> action.run());
39
+ layout.addView(button, new LinearLayout.LayoutParams(-1, 120));
40
+ }
41
+ private void home() { screen(Selectors.HOME_SCREEN); button(Selectors.SAVED_SESSION, () -> detail()); }
42
+ private void detail() {
43
+ screen(active ? Selectors.ACTIVE_DETAIL : Selectors.IDLE_DETAIL);
44
+ button(active ? Selectors.DEACTIVATE : Selectors.ACTIVATE, () -> { active = !active; detail(); });
45
+ button(Selectors.HOME, () -> home());
46
+ button(Selectors.STOP, () -> { active = false; screen(Selectors.STOPPED); });
47
+ }
48
+ }
@@ -226,7 +226,7 @@ async function run() {
226
226
  const result = await ToolsInteract.waitForUIHandler({
227
227
  selector: { text: `Button ${i}` },
228
228
  condition: 'exists',
229
- timeout_ms: 200,
229
+ timeout_ms: 1000,
230
230
  poll_interval_ms: 50,
231
231
  platform: 'android'
232
232
  })
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AndroidManage } from '../../../src/manage/android.js'
3
+ import { iOSManage } from '../../../src/manage/ios.js'
4
+ import { remainingBudget, runWithBudget } from '../../../src/utils/operation-budget.js'
5
+
6
+ async function run() {
7
+ const androidTerminate = AndroidManage.prototype.terminateApp
8
+ const androidStart = AndroidManage.prototype.startApp
9
+ const iosTerminate = iOSManage.prototype.terminateApp
10
+ const iosStart = iOSManage.prototype.startApp
11
+ const device = { platform: 'android', id: 'fixture' } as any
12
+ let androidLaunchBudget = 0
13
+ let iosLaunchBudget = 0
14
+ try {
15
+ AndroidManage.prototype.terminateApp = async () => ({ device, appTerminated: true })
16
+ AndroidManage.prototype.startApp = async (_appId, _deviceId, timeoutMs) => {
17
+ androidLaunchBudget = timeoutMs ?? 0
18
+ return { device, appStarted: true } as any
19
+ }
20
+ iOSManage.prototype.terminateApp = async () => ({ device, appTerminated: true })
21
+ iOSManage.prototype.startApp = async () => {
22
+ iosLaunchBudget = remainingBudget()
23
+ return { device, appStarted: true } as any
24
+ }
25
+ await runWithBudget(30000, () => new AndroidManage().restartApp('fixture', 'fixture', true, 30000))
26
+ await runWithBudget(30000, () => new iOSManage().restartApp('fixture', 'fixture', false, true, 30000))
27
+ assert.ok(androidLaunchBudget > 15000, `Android launch received ${androidLaunchBudget}ms`)
28
+ assert.ok(iosLaunchBudget > 15000, `iOS launch received ${iosLaunchBudget}ms`)
29
+ } finally {
30
+ AndroidManage.prototype.terminateApp = androidTerminate
31
+ AndroidManage.prototype.startApp = androidStart
32
+ iOSManage.prototype.terminateApp = iosTerminate
33
+ iOSManage.prototype.startApp = iosStart
34
+ }
35
+ }
36
+
37
+ run().catch(error => { console.error(error); process.exitCode = 1 })