mobile-debug-mcp 0.36.0 → 0.37.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 (171) hide show
  1. package/README.md +10 -0
  2. package/agent-plugin/.antigravity-plugin/plugin.json +1 -1
  3. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  4. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  5. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  6. package/agent-plugin/.mcp.json +1 -1
  7. package/agent-plugin/cursor/.cursor-plugin/plugin.json +2 -2
  8. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +10 -2
  9. package/agent-plugin/mcp.json +1 -1
  10. package/agent-plugin/mcp_config.json +1 -1
  11. package/agent-plugin/plugin.json +1 -1
  12. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +10 -2
  13. package/dist/contracts/device-readiness.js +1 -0
  14. package/dist/interact/waits/ui-change-wait.js +9 -2
  15. package/dist/interact/waits/ui-wait.js +7 -3
  16. package/dist/manage/index.js +3 -0
  17. package/dist/observe/device-readiness.js +116 -0
  18. package/dist/observe/evidence.js +31 -0
  19. package/dist/observe/ios-trace/sessions.js +5 -3
  20. package/dist/observe/readiness-service.js +21 -9
  21. package/dist/observe/readiness.js +30 -0
  22. package/dist/observe/screenshot-encoding.js +4 -0
  23. package/dist/observe/snapshot-collection.js +7 -2
  24. package/dist/server/definitions/manage.js +3 -2
  25. package/dist/server/handlers/action-execution.js +23 -14
  26. package/dist/server/handlers/compact-interaction.js +106 -0
  27. package/dist/server/handlers/compact-state.js +47 -0
  28. package/dist/server/handlers/input.js +2 -0
  29. package/dist/server/handlers/interact.js +2 -0
  30. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  31. package/dist/server/handlers/journey.js +18 -20
  32. package/dist/server/handlers/observe.js +2 -1
  33. package/dist/server/handlers/tool-handlers.js +9 -5
  34. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  35. package/dist/server/tool-definitions.js +9 -4
  36. package/dist/server-core.js +5 -2
  37. package/dist/server.js +1 -1
  38. package/dist/system/android.js +19 -15
  39. package/dist/system/index.js +2 -1
  40. package/dist/utils/android/utils.js +7 -16
  41. package/dist/utils/app-installation.js +77 -0
  42. package/dist/utils/benchmark-metrics.js +43 -0
  43. package/dist/utils/exec.js +4 -0
  44. package/dist/utils/ios/utils.js +4 -11
  45. package/dist/utils/resolve-device.js +3 -2
  46. package/dist/utils/timing.js +3 -3
  47. package/docs/CHANGELOG.md +25 -0
  48. package/docs/benchmarks/README.md +1 -0
  49. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  50. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  51. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  52. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  53. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  54. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  55. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  56. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  57. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  58. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  59. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  60. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  61. package/docs/specs/031-ios-system-traces/plan.md +28 -0
  62. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  63. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  64. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  65. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  66. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  67. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  68. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  69. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  70. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  71. package/docs/specs/034-app-installation-status/intent.md +41 -0
  72. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  73. package/docs/specs/034-app-installation-status/plan.md +73 -0
  74. package/docs/specs/034-app-installation-status/spec.md +114 -0
  75. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  76. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  77. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  78. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  79. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  80. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  81. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  82. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  83. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  84. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  85. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  86. package/docs/tools/TOOLS.md +1 -0
  87. package/docs/tools/compact-interactions.md +56 -0
  88. package/docs/tools/correlated-evidence.md +6 -0
  89. package/docs/tools/interact.md +17 -8
  90. package/docs/tools/manage.md +10 -2
  91. package/docs/tools/observe.md +42 -2
  92. package/docs/tools/system.md +6 -0
  93. package/package.json +1 -1
  94. package/src/contracts/device-readiness.ts +8 -0
  95. package/src/interact/waits/ui-change-wait.ts +7 -2
  96. package/src/interact/waits/ui-wait.ts +6 -3
  97. package/src/manage/index.ts +2 -0
  98. package/src/observe/device-readiness.ts +96 -0
  99. package/src/observe/evidence.ts +30 -1
  100. package/src/observe/ios-trace/sessions.ts +4 -2
  101. package/src/observe/readiness-service.ts +21 -9
  102. package/src/observe/readiness.ts +33 -0
  103. package/src/observe/screenshot-encoding.ts +4 -0
  104. package/src/observe/snapshot-collection.ts +7 -2
  105. package/src/server/common.ts +2 -2
  106. package/src/server/definitions/manage.ts +3 -2
  107. package/src/server/handlers/action-execution.ts +22 -14
  108. package/src/server/handlers/compact-interaction.ts +91 -0
  109. package/src/server/handlers/compact-state.ts +43 -0
  110. package/src/server/handlers/input.ts +2 -0
  111. package/src/server/handlers/interact.ts +2 -0
  112. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  113. package/src/server/handlers/journey.ts +15 -17
  114. package/src/server/handlers/observe.ts +2 -1
  115. package/src/server/handlers/payload.ts +2 -1
  116. package/src/server/handlers/tool-handlers.ts +9 -5
  117. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  118. package/src/server/tool-definitions.ts +8 -4
  119. package/src/server-core.ts +5 -2
  120. package/src/server.ts +1 -1
  121. package/src/system/android.ts +18 -11
  122. package/src/system/index.ts +2 -1
  123. package/src/types.ts +2 -0
  124. package/src/utils/android/utils.ts +8 -10
  125. package/src/utils/app-installation.ts +73 -0
  126. package/src/utils/benchmark-metrics.ts +42 -0
  127. package/src/utils/exec.ts +5 -0
  128. package/src/utils/ios/utils.ts +4 -10
  129. package/src/utils/resolve-device.ts +4 -2
  130. package/src/utils/timing.ts +3 -2
  131. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  132. package/test/device/automated/debugger/runtime.ios.test.ts +7 -1
  133. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  134. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  135. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  136. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  137. package/test/device/automated/latency/end-to-end.ts +149 -0
  138. package/test/device/automated/latency/owned-transport.ts +81 -0
  139. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  140. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  141. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  142. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  143. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  144. package/test/device/automated/observe/ios-system-trace.smoke.ts +2 -2
  145. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  146. package/test/fixtures/contracts/tool-definitions.json +51 -15
  147. package/test/fixtures/latency-journey-app/build.ts +3 -3
  148. package/test/unit/debugger/handle-guards.test.ts +2 -1
  149. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  150. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  151. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  152. package/test/unit/observe/device-readiness.test.ts +97 -0
  153. package/test/unit/observe/ios-trace-sessions.test.ts +23 -0
  154. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  155. package/test/unit/server/compact-interaction.test.ts +87 -0
  156. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  157. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  158. package/test/unit/server/conditional_actions.test.ts +7 -3
  159. package/test/unit/server/correlated-evidence.test.ts +18 -6
  160. package/test/unit/server/end-to-end-report.test.ts +46 -0
  161. package/test/unit/server/input-device-readiness.test.ts +55 -0
  162. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  163. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  164. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  165. package/test/unit/server/readiness.test.ts +24 -1
  166. package/test/unit/server/run_journey.test.ts +2 -1
  167. package/test/unit/server/selector_tap_target.test.ts +2 -1
  168. package/test/unit/server/system-trace.test.ts +6 -4
  169. package/test/unit/utils/app-installation.test.ts +78 -0
  170. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  171. package/test/unit/utils/resolve_device.test.ts +11 -0
package/dist/server.js CHANGED
@@ -7,7 +7,7 @@ import { getSystemStatus } from './system/index.js';
7
7
  const server = createServer();
8
8
  if (process.env.MOBILE_DEBUG_MCP_STARTUP_HEALTHCHECK === '1') {
9
9
  getSystemStatus().then((res) => {
10
- console.info('[startup] system status summary:', { adb: res.adbAvailable, ios: res.iosAvailable, devices: res.devices, iosDevices: res.iosDevices });
10
+ console.info('[startup] system status summary:', { adb: 'adbAvailable' in res ? res.adbAvailable : false, ios: 'iosAvailable' in res ? res.iosAvailable : false, devices: 'devices' in res ? res.devices : 0, iosDevices: 'iosDevices' in res ? res.iosDevices : 0 });
11
11
  }).catch((e) => console.warn('[startup] healthcheck failed:', e instanceof Error ? e.message : String(e)));
12
12
  }
13
13
  const transport = new StdioServerTransport();
@@ -1,3 +1,4 @@
1
+ import { probeInstallation, validAppId } from '../utils/app-installation.js';
1
2
  import { execSync } from 'child_process';
2
3
  import { ensureAdbAvailable } from '../utils/android/utils.js';
3
4
  export async function checkAndroid() {
@@ -9,6 +10,8 @@ export async function checkAndroid() {
9
10
  let logsAvailable = false;
10
11
  let envValid = false;
11
12
  let appInstalled = undefined;
13
+ let appInstallations = [];
14
+ let targets = [];
12
15
  try {
13
16
  const adbCheck = ensureAdbAvailable();
14
17
  const adbCmd = adbCheck.adbCmd || 'adb';
@@ -19,6 +22,7 @@ export async function checkAndroid() {
19
22
  try {
20
23
  const out = execSync(`${adbCmd} devices -l`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
21
24
  const state = parseDevices(out, issues);
25
+ targets = out.split('\n').map(line => line.trim().split(/\s+/)).filter(parts => parts[0] && !['List', '*'].includes(parts[0])).map(parts => ({ id: parts[0], available: parts[1] === 'device' }));
22
26
  devices = state.devices;
23
27
  deviceStates = state.deviceStates;
24
28
  }
@@ -43,15 +47,15 @@ export async function checkAndroid() {
43
47
  if (!envValid)
44
48
  issues.push('ANDROID_SDK_ROOT/ANDROID_HOME missing and adb not found in PATH');
45
49
  const pkg = process.env.MCP_TARGET_PACKAGE || process.env.MCP_TARGET_APP_ID;
46
- if (pkg && adbAvailable && devices > 0) {
47
- appInstalled = probeAppInstalled(adbCmd, pkg, issues);
48
- }
50
+ appInstallations = await Promise.all(targets.map(target => probeInstallation(adbCmd, target.id, pkg, 'android-current-user', adbAvailable && target.available)));
51
+ reportInstallations(appInstallations, pkg, issues);
52
+ appInstalled = installationSummary(appInstallations);
49
53
  }
50
54
  catch (e) {
51
55
  console.debug('[get_system_status] adb availability check failed: ' + String(e));
52
56
  issues.push('ADB check failed');
53
57
  }
54
- return { adbAvailable, adbVersion, devices, deviceStates, logsAvailable, envValid, appInstalled, issues };
58
+ return { adbAvailable, adbVersion, devices, deviceStates, logsAvailable, envValid, appInstalled, appInstallations, issues };
55
59
  }
56
60
  function parseDevices(out, issues) {
57
61
  const lines = out.split('\n').map(l => l.trim()).filter(Boolean);
@@ -73,16 +77,16 @@ function parseDevices(out, issues) {
73
77
  issues.push(`${stateCounts['offline']} device(s) offline`);
74
78
  return { devices, deviceStates };
75
79
  }
76
- function probeAppInstalled(adbCmd, pkg, issues) {
77
- try {
78
- const pm = execSync(`${adbCmd} shell pm path ${pkg}`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
79
- const appInstalled = (pm || '').includes('package:');
80
- if (!appInstalled)
81
- issues.push(`App ${pkg} not installed on devices`);
82
- return appInstalled;
83
- }
84
- catch (e) {
85
- console.debug('[get_system_status] pm check failed: ' + String(e));
86
- return false;
80
+ function installationSummary(checks) {
81
+ return checks.length === 1 && checks[0].status !== 'unknown' ? checks[0].status === 'installed' : undefined;
82
+ }
83
+ function reportInstallations(checks, pkg, issues) {
84
+ if (pkg !== undefined && !validAppId(pkg))
85
+ issues.push('Invalid target app identifier');
86
+ for (const check of checks) {
87
+ if (check.status === 'absent')
88
+ issues.push(`App ${check.appId} not installed on device ${check.deviceId}`);
89
+ if (check.status === 'unknown' && check.reason !== 'APP_ID_NOT_PROVIDED')
90
+ issues.push(`Installation check unavailable on device ${check.deviceId}: ${check.reason}`);
87
91
  }
88
92
  }
@@ -40,7 +40,8 @@ export async function getSystemStatus() {
40
40
  logsAvailable: androidRaw.logsAvailable,
41
41
  envValid: androidRaw.envValid,
42
42
  issues,
43
- appInstalled: androidRaw.appInstalled,
43
+ ...(androidRaw.appInstalled === undefined ? {} : { appInstalled: androidRaw.appInstalled }),
44
+ appInstallations: androidRaw.appInstallations,
44
45
  iosAvailable: iosRaw.iosAvailable,
45
46
  iosDevices: iosRaw.iosDevices,
46
47
  gradleJavaHome: gradle.gradleJavaHome,
@@ -1,7 +1,8 @@
1
1
  import { promises as fsPromises } from 'fs';
2
2
  import path from 'path';
3
3
  export { findInPath, resolveAdbCmd, getAdbCmd, ensureAdbAvailable, getAdbArgs, getAdbTimeout, execAdb, spawnAdb } from './commands.js';
4
- import { execAdb } from './commands.js';
4
+ import { execAdb, getAdbCmd } from './commands.js';
5
+ import { installationFields, probeInstallation } from '../app-installation.js';
5
6
  export { mergeEnv, prepareGradle } from './gradle.js';
6
7
  export { parseBounds, getCenter, parseBooleanAttr, parseNumberAttr, normalizeClassName, inferAndroidRole, buildAndroidSelectorConfidence, buildAndroidSelector, buildAndroidSemantic, isSliderLikeAndroid, extractAndroidState, traverseNode } from './ui-tree.js';
7
8
  export { parseLogLine } from './logs.js';
@@ -79,22 +80,12 @@ export async function listAndroidDevices(appId) {
79
80
  execAdb(['shell', 'getprop', 'ro.kernel.qemu'], serial).catch(() => '0')
80
81
  ]);
81
82
  const simulator = simOutput === '1';
82
- let appInstalled = false;
83
- if (appId) {
84
- try {
85
- const pm = await execAdb(['shell', 'pm', 'path', appId], serial);
86
- appInstalled = !!(pm && pm.includes('package:'));
87
- }
88
- catch (e) {
89
- console.debug(`[listAndroidDevices] pm check failed for ${serial}: ${String(e)}`);
90
- appInstalled = false;
91
- }
92
- }
93
- return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled };
83
+ const check = await probeInstallation(getAdbCmd(), serial, appId, 'android-current-user', deviceLines.find(line => line.split(/\s+/)[0] === serial)?.split(/\s+/)[1] === 'device');
84
+ return { platform: 'android', id: serial, osVersion, model, simulator, ...installationFields(check) };
94
85
  }
95
- catch (e) {
96
- console.debug(`[listAndroidDevices] failed gathering metadata for ${serial}: ${String(e)}`);
97
- return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false };
86
+ catch {
87
+ const check = await probeInstallation(getAdbCmd(), serial, appId, 'android-current-user', false);
88
+ return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, ...installationFields(check) };
98
89
  }
99
90
  }));
100
91
  return infos;
@@ -0,0 +1,77 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { execCmd } from './exec.js';
3
+ import { remainingBudget } from './operation-budget.js';
4
+ export function validAppId(appId) { return /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(appId); }
5
+ export function installationContext(deviceId, appId, scope) {
6
+ return { status: 'unknown', appId: appId ?? null, deviceId, scope, checkedAt: null, reason: appId === undefined ? 'APP_ID_NOT_PROVIDED' : 'CHECK_FAILED' };
7
+ }
8
+ export function installationFields(appInstallation) {
9
+ return { appInstallation, ...(appInstallation.status === 'unknown' ? {} : { appInstalled: appInstallation.status === 'installed' }) };
10
+ }
11
+ export async function probeInstallation(command, deviceId, appId, scope, available = true) {
12
+ const result = installationContext(deviceId, appId, scope);
13
+ if (appId === undefined || !validAppId(appId))
14
+ return result;
15
+ if (!available)
16
+ return { ...result, reason: 'DEVICE_UNAVAILABLE' };
17
+ try {
18
+ return await runProbe(command, result);
19
+ }
20
+ catch {
21
+ return { ...result, checkedAt: new Date().toISOString(), reason: 'CHECK_FAILED' };
22
+ }
23
+ }
24
+ async function runProbe(command, context) {
25
+ const android = context.scope === 'android-current-user';
26
+ const started = performance.now();
27
+ const timeout = remainingBudget(5000);
28
+ const args = android ? ['-s', context.deviceId, 'shell', 'pm', 'path', context.appId] : ['simctl', 'listapps', context.deviceId];
29
+ const output = await execCmd(command, args, { timeout });
30
+ const result = { ...context, checkedAt: new Date().toISOString() };
31
+ if (output.exitCode !== 0) {
32
+ if (android && !output.stdout && !output.stderr)
33
+ return confirmAndroidAbsence(command, result, Math.max(1, timeout - (performance.now() - started)));
34
+ return { ...result, reason: 'CHECK_FAILED' };
35
+ }
36
+ let status;
37
+ if (android) {
38
+ const lines = output.stdout.trim().split('\n').filter(Boolean);
39
+ if (lines.some(line => !/^package:\/\S+$/.test(line.trim())))
40
+ return { ...result, reason: 'UNRECOGNIZED_RESPONSE' };
41
+ status = lines.length ? 'installed' : 'absent';
42
+ }
43
+ else {
44
+ try {
45
+ const budget = remainingBudget(Math.max(1, Math.floor(timeout - (performance.now() - started))));
46
+ if (performance.now() - started >= timeout)
47
+ return { ...result, reason: 'CHECK_FAILED' };
48
+ const json = await new Promise((resolve, reject) => {
49
+ const child = execFile('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '--', '-'], { timeout: budget, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => error ? reject(error) : resolve(stdout));
50
+ child.stdin?.on('error', reject);
51
+ child.stdin?.end(output.stdout);
52
+ });
53
+ const apps = JSON.parse(json);
54
+ if (!apps || typeof apps !== 'object' || Array.isArray(apps))
55
+ return { ...result, reason: 'UNRECOGNIZED_RESPONSE' };
56
+ if (Object.values(apps).some(value => !value || typeof value !== 'object' || Array.isArray(value)))
57
+ return { ...result, reason: 'UNRECOGNIZED_RESPONSE' };
58
+ status = Object.hasOwn(apps, context.appId) ? 'installed' : 'absent';
59
+ }
60
+ catch {
61
+ return { ...result, reason: 'UNRECOGNIZED_RESPONSE' };
62
+ }
63
+ }
64
+ const { reason: _reason, ...confirmed } = result;
65
+ return { ...confirmed, status };
66
+ }
67
+ async function confirmAndroidAbsence(command, context, timeout) {
68
+ const output = await execCmd(command, ['-s', context.deviceId, 'shell', 'pm', 'list', 'packages', context.appId], { timeout });
69
+ const result = { ...context, checkedAt: new Date().toISOString() };
70
+ if (output.exitCode !== 0)
71
+ return { ...result, reason: 'CHECK_FAILED' };
72
+ const lines = output.stdout.split('\n').map(line => line.trim()).filter(Boolean);
73
+ if (lines.some(line => !/^package:[A-Za-z0-9_.-]+$/.test(line)))
74
+ return { ...result, reason: 'UNRECOGNIZED_RESPONSE' };
75
+ const { reason: _reason, ...confirmed } = result;
76
+ return { ...confirmed, status: lines.includes(`package:${context.appId}`) ? 'installed' : 'absent' };
77
+ }
@@ -0,0 +1,43 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ const measurement = new AsyncLocalStorage();
4
+ // Private benchmark diagnostics: no arguments, device IDs or response contents.
5
+ export function recordBenchmarkSpan(stage, duration) {
6
+ const current = measurement.getStore();
7
+ if (!current)
8
+ return;
9
+ const end = performance.now() - current.started;
10
+ current.spans.push({ stage, start_ms: Math.max(0, end - duration), end_ms: end });
11
+ }
12
+ export async function benchmarkSubprocess(operation) {
13
+ return benchmarkStage('subprocess_ms', operation);
14
+ }
15
+ export async function benchmarkStage(stage, operation) {
16
+ if (!measurement.getStore())
17
+ return operation();
18
+ const started = performance.now();
19
+ try {
20
+ return await operation();
21
+ }
22
+ finally {
23
+ recordBenchmarkSpan(stage, performance.now() - started);
24
+ }
25
+ }
26
+ export async function withBenchmarkRequest(tool, operation) {
27
+ if (process.env.MOBILE_DEBUG_MCP_LATENCY_METRICS !== '1')
28
+ return operation();
29
+ const current = { id: randomUUID(), started: performance.now(), spans: [] };
30
+ return measurement.run(current, async () => {
31
+ let completed = false;
32
+ try {
33
+ const result = await operation();
34
+ completed = true;
35
+ return result;
36
+ }
37
+ finally {
38
+ process.stderr.write(`${JSON.stringify({ event: 'latency_metric', request_id: current.id,
39
+ tool, handler_completed: completed, elapsed_ms: performance.now() - current.started,
40
+ spans: current.spans })}\n`);
41
+ }
42
+ });
43
+ }
@@ -1,7 +1,11 @@
1
1
  import { checkDebuggerGuard } from '../debugger/guard.js';
2
2
  import { remainingBudget } from './operation-budget.js';
3
3
  import { spawn } from 'child_process';
4
+ import { benchmarkSubprocess } from './benchmark-metrics.js';
4
5
  export async function execCmd(cmd, args, opts = {}) {
6
+ return benchmarkSubprocess(() => execute(cmd, args, opts));
7
+ }
8
+ async function execute(cmd, args, opts) {
5
9
  const { env, cwd, shell } = opts;
6
10
  const timeout = remainingBudget(opts.timeout ?? 0);
7
11
  return new Promise((resolve, reject) => {
@@ -1,3 +1,4 @@
1
+ import { installationFields, probeInstallation } from '../app-installation.js';
1
2
  import { checkDebuggerGuard } from '../../debugger/guard.js';
2
3
  import { remainingBudget } from '../operation-budget.js';
3
4
  import { execFile, spawn, execSync, spawnSync } from "child_process";
@@ -279,17 +280,9 @@ export async function listIOSDevices(appId) {
279
280
  simulator: true,
280
281
  booted: bootedSet.has(device.udid)
281
282
  };
282
- if (appId) {
283
- // check if installed
284
- const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
285
- .then(() => { info.appInstalled = true; })
286
- .catch(() => { info.appInstalled = false; })
287
- .then(() => { out.push(info); });
288
- checks.push(p);
289
- }
290
- else {
291
- out.push(info);
292
- }
283
+ out.push(info);
284
+ checks.push(probeInstallation(getXcrunCmd(), device.udid, appId, 'ios-simulator', info.booted === true)
285
+ .then(check => { Object.assign(info, installationFields(check)); }));
293
286
  }
294
287
  }
295
288
  Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
@@ -38,6 +38,7 @@ export async function listDevices(platform, appId) {
38
38
  return iosDeviceLister(appId);
39
39
  }
40
40
  const deviceContext = new AsyncLocalStorage();
41
+ export function currentResolvedDevice() { return deviceContext.getStore(); }
41
42
  export function withResolvedDevice(device, operation) {
42
43
  bindEvidenceDevice(device);
43
44
  return deviceContext.run(device, operation);
@@ -75,7 +76,7 @@ function selectDevice(devices, platform, appId, prefer) {
75
76
  if (prefer === "emulator")
76
77
  candidates = candidates.filter(d => d.simulator);
77
78
  if (appId) {
78
- const installed = candidates.filter(d => d.appInstalled);
79
+ const installed = candidates.filter(d => d.appInstallation ? d.appInstallation.status === 'installed' && d.appInstallation.appId === appId && d.appInstallation.deviceId === d.id : d.appInstalled === true);
79
80
  if (installed.length > 0)
80
81
  candidates = installed;
81
82
  }
@@ -101,7 +102,7 @@ function selectDevice(devices, platform, appId, prefer) {
101
102
  if (candidates.length > 1 && parseNumericVersion(candidates[0].osVersion) > parseNumericVersion(candidates[1].osVersion)) {
102
103
  return candidates[0];
103
104
  }
104
- const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled }));
105
+ const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled, appInstallation: d.appInstallation }));
105
106
  const err = Object.assign(new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`), { devices: list });
106
107
  throw err;
107
108
  }
@@ -1,7 +1,9 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { recordBenchmarkSpan } from './benchmark-metrics.js';
2
3
  const timings = new AsyncLocalStorage();
3
4
  export function withTiming(values, operation) { return timings.run(values, operation); }
4
5
  export function recordTiming(component, milliseconds) {
6
+ recordBenchmarkSpan(component, milliseconds);
5
7
  const values = timings.getStore();
6
8
  if (values)
7
9
  values[component] = (values[component] ?? 0) + milliseconds;
@@ -12,8 +14,6 @@ export async function measure(component, operation) {
12
14
  return await operation();
13
15
  }
14
16
  finally {
15
- const values = timings.getStore();
16
- if (values)
17
- values[component] = (values[component] ?? 0) + performance.now() - start;
17
+ recordTiming(component, performance.now() - start);
18
18
  }
19
19
  }
package/docs/CHANGELOG.md CHANGED
@@ -8,6 +8,31 @@ and [tool reference](tools/TOOLS.md) for the current checkout.
8
8
 
9
9
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
10
10
 
11
+ ## [0.37.0]
12
+
13
+ - Updated the agent plugin to `0.0.7`, pinned to MCP `0.37.0`.
14
+
15
+ - Android debug snapshots use one fresh UI tree for the returned hierarchy and
16
+ screen fingerprint, removing a duplicate acquisition.
17
+
18
+ - Add an end-to-end latency benchmark with repeated mode comparisons, visible
19
+ failed attempts, source/build identity, and opt-in timing-only request and
20
+ subprocess diagnostics. Public tool contracts and verification are unchanged.
21
+
22
+ - Compact interaction/journey results now summarize observations, readiness, conditions and state deltas within explicit byte budgets. Detailed original results are available through session-local `get_interaction_diagnostics` references without replaying input. Debug output and response-mode defaults are unchanged.
23
+
24
+ - Report device-scoped app installation as installed, absent or unknown. Publish
25
+ `list_devices.appId`; omit legacy installation booleans for inconclusive or
26
+ multi-device system checks instead of reporting false absence.
27
+
28
+ - Clean up discarded iOS trace files after a recorder-stop failure when process
29
+ exit is confirmed, while retaining the failure and protecting live recordings.
30
+
31
+ - Add timestamped device-readiness evidence to launch/interaction results and
32
+ readiness waits, preserving dispatch outcomes and compact/journey evidence.
33
+ Android power/keyguard blockers prevent readiness success; iOS reports
34
+ per-signal unknown/unsupported evidence. No automatic wake or unlock.
35
+
11
36
  ## [0.36.0]
12
37
 
13
38
  - Updated the agent plugin to `0.0.6`, pinned to MCP `0.36.0`.
@@ -14,5 +14,6 @@ iOS latency.
14
14
  | --- | --- | --- | --- |
15
15
  | Interaction latency | [Run the benchmark](interaction-latency/README.md) | [Android measurements](interaction-latency/final.md), [iOS interaction validation](interaction-latency/ios-validation.md) | [018](../specs/018-interaction-latency-and-batched-journeys/spec.md) |
16
16
  | UI-tree observation | [Run the paired benchmark](ui-tree-performance/README.md) | [Comparison and limitations](ui-tree-performance/comparison.md) | [019](../specs/019-ui-tree-observation-performance/spec.md) |
17
+ | End-to-end interaction latency | [Run the benchmarks](end-to-end-latency/README.md) | [Provisional Android results and iOS blocker](end-to-end-latency/investigation.md) | [036](../specs/036-end-to-end-interaction-latency/spec.md) |
17
18
 
18
19
  Fixture instructions: [latency journey app](../../test/fixtures/latency-journey-app/README.md).
@@ -0,0 +1,98 @@
1
+ # End-to-end interaction latency
2
+
3
+ [Benchmark index](../README.md) · [Specification](../../specs/036-end-to-end-interaction-latency/spec.md)
4
+
5
+ This investigation reuses the Spec 018 Android fixture and Spec 019 tree metrics.
6
+ It does not replace their historical results or optimization acceptance gates.
7
+ Requires Node >=22, Java supporting `--release 8`, Android platform 36 and
8
+ build-tools 36.0.0. Start an Android emulator, then run from the repository root:
9
+
10
+ ```sh
11
+ npm run verify
12
+ LATENCY_DEVICE_ID=emulator-5554 MCP_ADB_TIMEOUT=30000 npx tsx test/device/automated/latency/end-to-end.ts --smoke
13
+ LATENCY_DEVICE_ID=emulator-5554 MCP_ADB_TIMEOUT=30000 npx tsx test/device/automated/latency/end-to-end.ts
14
+ ```
15
+
16
+ Only explicitly named Android emulators are accepted; physical phones are not
17
+ selected. Existing device resolution can enumerate other attached devices;
18
+ the report records discovery counts without serials. No display configuration is changed. The dedicated fixture is built,
19
+ installed, and reset; an installation created by this run is removed on exit.
20
+ A pre-existing fixture installation is left installed, with reset fixture data.
21
+ The harness owns isolated POSIX server process groups, closes them on exit and
22
+ interrupt, and verifies their termination. An unsafe timeout stops the run;
23
+ semantic failures are retained before the next explicitly recorded reset.
24
+
25
+ The smoke uses one attempt per case and writes `smoke.json`/`smoke.md`; it is not
26
+ performance evidence. The full run writes `baseline.json`/`baseline.md` after
27
+ each attempt, preserving partial results on failure/interruption. It performs
28
+ three warm-ups and ten measured attempts per case, reversing mode order every
29
+ round. Do not run other device workloads concurrently.
30
+
31
+ `baseline-attempt-1.json`/`.md` retain the first failed warm-up run: the emulator
32
+ disappeared, the next reset failed, and fixture cleanup could not be confirmed.
33
+ No measured samples were produced. Both owned server groups exited. The retry
34
+ restores the same AVD headlessly using `emulator -avd Pixel_9_Pro -port 5554
35
+ -no-window -no-snapshot-save`; it does not wipe data. This startup configuration
36
+ is distinct from the earlier development smoke and failed attempt.
37
+
38
+ Cases compare the original standalone handle flow and four full/light ×
39
+ debug/compact journeys, retaining all six destination assertions. Screenshot,
40
+ UI tree and screenshot/log-free snapshot calls are separate cases on the known
41
+ fixture home screen, with instrumentation on and off. Light/compact has existing
42
+ assertion-based observation sharing; mode differences can therefore include
43
+ acquisition counts as well as response construction costs. They are not a pure
44
+ serialization microbenchmark.
45
+
46
+ Raw reports include source/build/harness/fixture hashes, dirty paths, environment,
47
+ request order, byte counts, timing-only events, setup/startup durations and
48
+ cleanup. Device serials, raw responses and unrestricted stderr are not retained.
49
+ Source/build/harness changes during measurement invalidate the run. Externally
50
+ run builds and validation suites are separate from MCP request time. This fixture
51
+ uses offline SDK tools rather than Gradle. Device application work inside requests
52
+ is included; it is not established as pure MCP overhead.
53
+
54
+ Percentiles use nearest rank and successful attempts only; failures remain in
55
+ raw records and fail the run. Warm-up failures are counted separately. Ten samples
56
+ provide a preliminary p95, not a robust tail estimate. Inclusive component spans
57
+ overlap and must not be summed. Handler residual subtracts their interval union;
58
+ client-minus-handler includes scheduling, transport and uninstrumented work.
59
+ Existing serialization spans cover selected response construction, not all SDK
60
+ wire serialization. Tree event stages expose acquisition, retry and parse costs.
61
+ Subprocess spans cover `execCmd`; direct platform spawns can remain in residuals.
62
+
63
+ Compare observation `on`/`off` results to assess probe overhead; these include
64
+ normal device variability and are not automatically a causal overhead estimate.
65
+ `missing_request_metrics` is expected for `off` cases only. Instrumentation uses
66
+ `MOBILE_DEBUG_MCP_LATENCY_METRICS=1` and the existing
67
+ `MOBILE_DEBUG_MCP_UI_TREE_METRICS=1` only in instrumented benchmark servers.
68
+ Normal MCP contracts and diagnostic output are unchanged.
69
+
70
+ For standalone iOS observations, use an isolated booted simulator with working
71
+ idb discovery and English Settings. The runner confirms the Settings root
72
+ before each timed request and records preparation time separately.
73
+
74
+ ```sh
75
+ IOS_LATENCY_DEVICE_ID=YOUR_SIMULATOR_ID npx tsx test/device/automated/latency-ios/benchmark.ts --smoke
76
+ IOS_LATENCY_DEVICE_ID=YOUR_SIMULATOR_ID npx tsx test/device/automated/latency-ios/benchmark.ts
77
+ ```
78
+
79
+ Equivalent iOS fixture journeys depend on [#89](https://github.com/clivejefferies/mobile-debug-tools/issues/89).
80
+ See the [investigation report](investigation.md) for the Android measurements
81
+ and the observed iOS setup blocker.
82
+
83
+ For a focused [Android snapshot comparison](android-snapshot-pair.md), build
84
+ the pre-change checkout and preserve its emitted `dist` directory and source
85
+ hash. The preserved build needs its ESM `package.json` and the same installed
86
+ dependencies. Build the candidate in the current checkout, then run:
87
+
88
+ ```sh
89
+ LATENCY_DEVICE_ID=emulator-5554 \
90
+ SNAPSHOT_BASELINE_SERVER=/absolute/path/to/baseline/dist/server.js \
91
+ SNAPSHOT_BASELINE_SOURCE_SHA=BASELINE_SOURCE_SHA256 \
92
+ MCP_ADB_TIMEOUT=30000 \
93
+ node --import tsx test/device/automated/latency/snapshot-pair.ts
94
+ ```
95
+
96
+ Use the `treeHash` helper in `end-to-end-environment.ts` to hash baseline `src`
97
+ before editing it. This command writes `android-snapshot-pair.json` with the
98
+ attempts and acceptance result.