mobile-debug-mcp 0.35.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 (247) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +96 -24
  4. package/agent-plugin/.antigravity-plugin/README.md +48 -0
  5. package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
  6. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  7. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  8. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  9. package/agent-plugin/.mcp.json +1 -1
  10. package/agent-plugin/README.md +37 -8
  11. package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
  12. package/agent-plugin/cursor/LICENSE +201 -0
  13. package/agent-plugin/cursor/README.md +9 -0
  14. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
  15. package/agent-plugin/mcp.json +1 -1
  16. package/agent-plugin/mcp_config.json +13 -0
  17. package/agent-plugin/plugin.json +1 -1
  18. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
  19. package/dist/contracts/device-readiness.js +1 -0
  20. package/dist/debugger/guard.js +4 -4
  21. package/dist/debugger/ios-worker.js +62 -0
  22. package/dist/debugger/ios.js +57 -0
  23. package/dist/debugger/sessions.js +33 -16
  24. package/dist/debugger/worker.js +6 -4
  25. package/dist/interact/index.js +12 -2
  26. package/dist/interact/waits/ui-change-wait.js +9 -2
  27. package/dist/interact/waits/ui-wait.js +7 -3
  28. package/dist/manage/index.js +3 -0
  29. package/dist/manage/ios.js +2 -0
  30. package/dist/observe/device-readiness.js +116 -0
  31. package/dist/observe/evidence.js +31 -0
  32. package/dist/observe/ios-trace/artifact.js +63 -0
  33. package/dist/observe/ios-trace/capabilities.js +86 -0
  34. package/dist/observe/ios-trace/native.js +93 -0
  35. package/dist/observe/ios-trace/notification.js +35 -0
  36. package/dist/observe/ios-trace/process.js +98 -0
  37. package/dist/observe/ios-trace/sessions.js +246 -0
  38. package/dist/observe/readiness-service.js +22 -10
  39. package/dist/observe/readiness.js +34 -4
  40. package/dist/observe/screenshot-encoding.js +4 -0
  41. package/dist/observe/snapshot-collection.js +7 -2
  42. package/dist/observe/system-trace-error.js +7 -0
  43. package/dist/observe/system-trace-router.js +81 -0
  44. package/dist/observe/system-trace.js +4 -8
  45. package/dist/server/definitions/manage.js +3 -2
  46. package/dist/server/handlers/action-execution.js +23 -14
  47. package/dist/server/handlers/compact-interaction.js +106 -0
  48. package/dist/server/handlers/compact-state.js +47 -0
  49. package/dist/server/handlers/debugger-handlers.js +27 -8
  50. package/dist/server/handlers/diagnostic.js +12 -4
  51. package/dist/server/handlers/input.js +2 -0
  52. package/dist/server/handlers/interact.js +2 -0
  53. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  54. package/dist/server/handlers/journey.js +18 -20
  55. package/dist/server/handlers/observe.js +2 -1
  56. package/dist/server/handlers/tool-handlers.js +28 -14
  57. package/dist/server/schemas/debugger-schema.js +10 -7
  58. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  59. package/dist/server/schemas/system-trace-schema.js +14 -9
  60. package/dist/server/tool-definitions.js +9 -4
  61. package/dist/server-core.js +5 -2
  62. package/dist/server.js +5 -5
  63. package/dist/system/android.js +19 -15
  64. package/dist/system/index.js +2 -1
  65. package/dist/utils/android/utils.js +7 -16
  66. package/dist/utils/app-installation.js +77 -0
  67. package/dist/utils/benchmark-metrics.js +43 -0
  68. package/dist/utils/exec.js +4 -0
  69. package/dist/utils/ios/utils.js +7 -11
  70. package/dist/utils/resolve-device.js +3 -2
  71. package/dist/utils/timing.js +3 -3
  72. package/docs/CHANGELOG.md +48 -1
  73. package/docs/agent-plugin-installation-verification.md +39 -0
  74. package/docs/benchmarks/README.md +1 -0
  75. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  76. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  77. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  78. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  79. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  80. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  81. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  82. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  83. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  84. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  85. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  86. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  87. package/docs/code-structure.md +3 -1
  88. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  89. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  90. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  91. package/docs/specs/031-ios-system-traces/plan.md +166 -0
  92. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  93. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  94. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  95. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  96. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  97. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  98. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  99. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  100. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  101. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  102. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  103. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  104. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  105. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  106. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  107. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  108. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  109. package/docs/specs/034-app-installation-status/intent.md +41 -0
  110. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  111. package/docs/specs/034-app-installation-status/plan.md +73 -0
  112. package/docs/specs/034-app-installation-status/spec.md +114 -0
  113. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  114. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  115. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  116. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  117. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  118. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  119. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  120. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  121. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  122. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  123. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  124. package/docs/tools/TOOLS.md +3 -2
  125. package/docs/tools/compact-interactions.md +56 -0
  126. package/docs/tools/correlated-evidence.md +6 -0
  127. package/docs/tools/debugger.md +45 -5
  128. package/docs/tools/interact.md +17 -8
  129. package/docs/tools/manage.md +10 -2
  130. package/docs/tools/observe.md +42 -2
  131. package/docs/tools/system-traces.md +81 -14
  132. package/docs/tools/system.md +6 -0
  133. package/package.json +2 -1
  134. package/scripts/agent-plugin.mjs +55 -5
  135. package/src/contracts/device-readiness.ts +8 -0
  136. package/src/debugger/android.ts +1 -1
  137. package/src/debugger/guard.ts +5 -5
  138. package/src/debugger/ios-worker.ts +49 -0
  139. package/src/debugger/ios.ts +45 -0
  140. package/src/debugger/lldb/breakpoints.py +51 -0
  141. package/src/debugger/lldb/inspection.py +109 -0
  142. package/src/debugger/lldb/worker.py +203 -0
  143. package/src/debugger/sessions.ts +24 -12
  144. package/src/debugger/worker.ts +7 -4
  145. package/src/interact/index.ts +13 -2
  146. package/src/interact/waits/ui-change-wait.ts +7 -2
  147. package/src/interact/waits/ui-wait.ts +6 -3
  148. package/src/manage/index.ts +2 -0
  149. package/src/manage/ios.ts +2 -0
  150. package/src/observe/device-readiness.ts +96 -0
  151. package/src/observe/evidence.ts +30 -1
  152. package/src/observe/ios-trace/artifact.ts +55 -0
  153. package/src/observe/ios-trace/capabilities.ts +74 -0
  154. package/src/observe/ios-trace/native.ts +72 -0
  155. package/src/observe/ios-trace/notification.ts +30 -0
  156. package/src/observe/ios-trace/process.ts +82 -0
  157. package/src/observe/ios-trace/sessions.ts +206 -0
  158. package/src/observe/readiness-service.ts +22 -10
  159. package/src/observe/readiness.ts +37 -4
  160. package/src/observe/screenshot-encoding.ts +4 -0
  161. package/src/observe/snapshot-collection.ts +7 -2
  162. package/src/observe/system-trace-error.ts +3 -0
  163. package/src/observe/system-trace-router.ts +61 -0
  164. package/src/observe/system-trace.ts +4 -4
  165. package/src/server/common.ts +2 -2
  166. package/src/server/definitions/manage.ts +3 -2
  167. package/src/server/handlers/action-execution.ts +22 -14
  168. package/src/server/handlers/compact-interaction.ts +91 -0
  169. package/src/server/handlers/compact-state.ts +43 -0
  170. package/src/server/handlers/debugger-handlers.ts +28 -9
  171. package/src/server/handlers/diagnostic.ts +11 -4
  172. package/src/server/handlers/input.ts +2 -0
  173. package/src/server/handlers/interact.ts +2 -0
  174. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  175. package/src/server/handlers/journey.ts +15 -17
  176. package/src/server/handlers/observe.ts +2 -1
  177. package/src/server/handlers/payload.ts +2 -1
  178. package/src/server/handlers/tool-handlers.ts +29 -14
  179. package/src/server/schemas/debugger-schema.ts +10 -7
  180. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  181. package/src/server/schemas/system-trace-schema.ts +15 -9
  182. package/src/server/tool-definitions.ts +8 -4
  183. package/src/server-core.ts +5 -2
  184. package/src/server.ts +4 -4
  185. package/src/system/android.ts +18 -11
  186. package/src/system/index.ts +2 -1
  187. package/src/types.ts +2 -0
  188. package/src/utils/android/utils.ts +8 -10
  189. package/src/utils/app-installation.ts +73 -0
  190. package/src/utils/benchmark-metrics.ts +42 -0
  191. package/src/utils/exec.ts +5 -0
  192. package/src/utils/ios/utils.ts +7 -10
  193. package/src/utils/resolve-device.ts +4 -2
  194. package/src/utils/timing.ts +3 -2
  195. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  196. package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
  197. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  198. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  199. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  200. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  201. package/test/device/automated/latency/end-to-end.ts +149 -0
  202. package/test/device/automated/latency/owned-transport.ts +81 -0
  203. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  204. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  205. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  206. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  207. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  208. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  209. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  210. package/test/fixtures/contracts/tool-definitions.json +243 -47
  211. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  212. package/test/fixtures/ios-debugger-app/README.md +27 -0
  213. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  214. package/test/fixtures/latency-journey-app/build.ts +3 -3
  215. package/test/helpers/ios-trace-shutdown.ts +29 -0
  216. package/test/unit/debugger/handle-guards.test.ts +89 -0
  217. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  218. package/test/unit/debugger/ios.test.ts +104 -0
  219. package/test/unit/debugger/lldb-worker.py +166 -0
  220. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  221. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  222. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  223. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  224. package/test/unit/observe/device-readiness.test.ts +97 -0
  225. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  226. package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
  227. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  228. package/test/unit/observe/readiness.test.ts +1 -1
  229. package/test/unit/observe/system-trace-router.test.ts +49 -0
  230. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  231. package/test/unit/server/compact-interaction.test.ts +87 -0
  232. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  233. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  234. package/test/unit/server/conditional_actions.test.ts +7 -3
  235. package/test/unit/server/correlated-evidence.test.ts +18 -6
  236. package/test/unit/server/end-to-end-report.test.ts +46 -0
  237. package/test/unit/server/input-device-readiness.test.ts +55 -0
  238. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  239. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  240. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  241. package/test/unit/server/readiness.test.ts +24 -1
  242. package/test/unit/server/run_journey.test.ts +2 -1
  243. package/test/unit/server/selector_tap_target.test.ts +2 -1
  244. package/test/unit/server/system-trace.test.ts +18 -7
  245. package/test/unit/utils/app-installation.test.ts +78 -0
  246. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  247. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -0,0 +1,90 @@
1
+ import { summarize } from './report.js'
2
+
3
+ export interface Span { stage: string; start_ms: number; end_ms: number }
4
+ export interface RequestMetric {
5
+ event: 'latency_metric'; request_id: string; tool: string
6
+ handler_completed: boolean; elapsed_ms: number; spans: Span[]
7
+ }
8
+ export interface TreeMetric {
9
+ event: 'ui_tree_metric'; type: string; stage?: string; elapsed_ms?: number
10
+ acquisition_id?: string; success?: boolean; durations_ms?: Record<string, number>
11
+ }
12
+ export interface RequestSample {
13
+ tool: string; elapsed_ms: number; response_bytes?: number; success: boolean
14
+ failure?: string; failed_step?: string; failure_code?: string; metrics: RequestMetric[]; tree: TreeMetric[]
15
+ }
16
+ export interface Attempt {
17
+ case: string; iteration: number; warmup: boolean; success: boolean
18
+ failure?: string; setup_ms: number; requests: RequestSample[]
19
+ }
20
+
21
+ export function unionDuration(spans: Span[], limit: number) {
22
+ const sorted = spans.map(span => [Math.max(0, span.start_ms), Math.min(limit, span.end_ms)])
23
+ .filter(([start, end]) => Number.isFinite(start) && Number.isFinite(end) && end >= start)
24
+ .sort((a, b) => a[0] - b[0])
25
+ let end = 0, total = 0
26
+ for (const [start, stop] of sorted) { total += Math.max(0, stop - Math.max(end, start)); end = Math.max(end, stop) }
27
+ return total
28
+ }
29
+
30
+ export function summarizeAttempts(attempts: Attempt[]) {
31
+ return Object.fromEntries([...new Set(attempts.map(attempt => attempt.case))].map(name => {
32
+ const selected = attempts.filter(attempt => attempt.case === name)
33
+ const measured = selected.filter(attempt => !attempt.warmup)
34
+ const successful = measured.filter(attempt => attempt.success)
35
+ const requests = successful.flatMap(attempt => attempt.requests)
36
+ const metrics = requests.flatMap(request => request.metrics)
37
+ const tree = requests.flatMap(request => request.tree)
38
+ const stages = [...new Set(metrics.flatMap(metric => metric.spans.map(span => span.stage)))]
39
+ const treeStages = [...new Set(tree.filter(event => event.type === 'stage').map(event => event.stage!))]
40
+ return [name, {
41
+ attempts: measured.length, failures: measured.filter(attempt => !attempt.success).length,
42
+ warmup_failures: selected.filter(attempt => attempt.warmup && !attempt.success).length,
43
+ total_ms: summarize(successful.map(attempt => attempt.requests.reduce((sum, request) => sum + request.elapsed_ms, 0))),
44
+ response_bytes: summarize(successful.map(attempt => attempt.requests.reduce((sum, request) => sum + (request.response_bytes ?? 0), 0))),
45
+ handler_ms: summarize(metrics.map(metric => metric.elapsed_ms)),
46
+ handler_unattributed_ms: summarize(metrics.map(metric => Math.max(0, metric.elapsed_ms - unionDuration(metric.spans, metric.elapsed_ms)))),
47
+ client_minus_handler_ms: summarize(requests.filter(request => request.metrics.length === 1)
48
+ .map(request => request.elapsed_ms - request.metrics[0].elapsed_ms)),
49
+ inclusive_components_ms: Object.fromEntries(stages.map(stage => [stage, summarize(metrics.map(metric =>
50
+ metric.spans.filter(span => span.stage === stage).reduce((sum, span) => sum + span.end_ms - span.start_ms, 0)))])),
51
+ physical_reads: tree.filter(event => event.type === 'physical_read_completed').length,
52
+ failed_physical_reads: tree.filter(event => event.type === 'physical_read_completed' && event.success === false).length,
53
+ tree_stages_ms: Object.fromEntries(treeStages.map(stage => [stage, summarize(tree.filter(event => event.type === 'stage' && event.stage === stage).map(event => event.elapsed_ms ?? 0))])),
54
+ tree_acquisition_ms: summarize(tree
55
+ .filter(event => event.type === 'acquisition_completed').map(event => event.elapsed_ms ?? 0)),
56
+ missing_request_metrics: requests.filter(request => request.metrics.length !== 1).length
57
+ }]
58
+ }))
59
+ }
60
+
61
+ export function observationOverhead(attempts: Attempt[]) {
62
+ return Object.fromEntries(['screenshot', 'tree', 'snapshot'].map(name => {
63
+ const pairs = attempts.filter(attempt => attempt.case === `${name}-on` && !attempt.warmup && attempt.success)
64
+ .flatMap(on => {
65
+ const off = attempts.find(attempt => attempt.case === `${name}-off` && attempt.iteration === on.iteration && !attempt.warmup && attempt.success)
66
+ return off ? [on.requests.reduce((sum, request) => sum + request.elapsed_ms, 0) - off.requests.reduce((sum, request) => sum + request.elapsed_ms, 0)] : []
67
+ })
68
+ return [name, { paired_on_minus_off_ms: summarize(pairs), interpretation: 'Includes device/order variability; not an isolated causal overhead estimate' }]
69
+ }))
70
+ }
71
+
72
+ // Whitelist timing metadata rather than persisting unrestricted server stderr.
73
+ export function parseMetric(line: string): RequestMetric | TreeMetric | null {
74
+ try {
75
+ const value = JSON.parse(line)
76
+ const finite = (number: unknown) => typeof number === 'number' && Number.isFinite(number) && number >= 0
77
+ if (value.event === 'latency_metric' && typeof value.tool === 'string' && typeof value.request_id === 'string' &&
78
+ typeof value.handler_completed === 'boolean' && finite(value.elapsed_ms) && Array.isArray(value.spans) &&
79
+ value.spans.every((span: Span) => typeof span.stage === 'string' && finite(span.start_ms) && finite(span.end_ms) && span.end_ms >= span.start_ms)) {
80
+ return { event: value.event, request_id: value.request_id, tool: value.tool,
81
+ handler_completed: value.handler_completed, elapsed_ms: value.elapsed_ms,
82
+ spans: value.spans.map((span: Span) => ({ stage: span.stage, start_ms: span.start_ms, end_ms: span.end_ms })) }
83
+ }
84
+ if (value.event === 'ui_tree_metric' && typeof value.type === 'string' &&
85
+ (value.elapsed_ms === undefined || finite(value.elapsed_ms))) return { event: value.event, type: value.type,
86
+ stage: value.stage, elapsed_ms: value.elapsed_ms, acquisition_id: value.acquisition_id,
87
+ success: value.success, durations_ms: value.durations_ms }
88
+ } catch { /* Non-metric stderr is intentionally discarded. */ }
89
+ return null
90
+ }
@@ -0,0 +1,98 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
+ import { setTimeout as delay } from 'node:timers/promises'
3
+ import { OwnedTransport } from './owned-transport.js'
4
+ import { parseMetric, type RequestMetric, type TreeMetric, type RequestSample } from './end-to-end-report.js'
5
+
6
+ function object(value: unknown): Record<string, unknown> {
7
+ return value !== null && typeof value === 'object' ? value as Record<string, unknown> : {}
8
+ }
9
+ export function validObservation(tool: string, data: Record<string, unknown>, content: unknown, deviceId: string) {
10
+ const treeValid = (value: Record<string, unknown>) => !value.error && Array.isArray(value.elements) && value.elements.length > 0 && object(value.device).id === deviceId
11
+ if (tool === 'get_ui_tree') return treeValid(data)
12
+ if (tool === 'capture_screenshot') return object(data.device).id === deviceId && Array.isArray(content) && content.some(item => {
13
+ const block = object(item)
14
+ return block.type === 'image' && typeof block.data === 'string' && block.data.length > 0
15
+ })
16
+ if (tool === 'capture_debug_snapshot') {
17
+ const raw = object(data.raw)
18
+ return treeValid(object(raw.ui_tree)) && !Object.keys(raw).some(key => key.endsWith('_error') && raw[key])
19
+ }
20
+ return true
21
+ }
22
+
23
+ export class UnsafeMeasurement extends Error {}
24
+ export class BenchmarkSession {
25
+ readonly client = new Client({ name: 'end-to-end-latency', version: '1.0' })
26
+ readonly transport: OwnedTransport
27
+ private events: (RequestMetric | TreeMetric)[] = []
28
+ private pending = ''
29
+ private interrupted = new AbortController()
30
+ constructor(readonly deviceId: string, readonly instrumented: boolean, server = 'dist/server.js') {
31
+ this.transport = new OwnedTransport({ ...process.env,
32
+ MOBILE_DEBUG_MCP_LATENCY_METRICS: instrumented ? '1' : '0',
33
+ MOBILE_DEBUG_MCP_UI_TREE_METRICS: instrumented ? '1' : '0'
34
+ }, chunk => {
35
+ this.pending += chunk
36
+ let index: number
37
+ while ((index = this.pending.indexOf('\n')) >= 0) {
38
+ const event = parseMetric(this.pending.slice(0, index))
39
+ if (event) this.events.push(event)
40
+ this.pending = this.pending.slice(index + 1)
41
+ }
42
+ if (this.pending.length > 1024 * 1024) this.pending = ''
43
+ }, [server])
44
+ }
45
+
46
+ async connect() { await this.client.connect(this.transport, { timeout: 15000 }) }
47
+ abort() { this.interrupted.abort() }
48
+ async close() { await this.client.close(); await this.transport.close() }
49
+
50
+ async call(tool: string, args: Record<string, unknown>, samples: RequestSample[]) {
51
+ if (this.interrupted.signal.aborted) throw new UnsafeMeasurement('INTERRUPTED')
52
+ this.events = []
53
+ const started = performance.now()
54
+ const sample: RequestSample = { tool, elapsed_ms: 0, success: false, metrics: [], tree: [] }
55
+ samples.push(sample)
56
+ try {
57
+ const response = await this.client.callTool({ name: tool,
58
+ arguments: { platform: 'android', deviceId: this.deviceId, ...args }
59
+ }, undefined, { timeout: 300000, signal: this.interrupted.signal })
60
+ sample.elapsed_ms = performance.now() - started
61
+ console.log(` ${tool}: ${Math.round(sample.elapsed_ms)} ms`)
62
+ sample.response_bytes = Buffer.byteLength(JSON.stringify(response))
63
+ // Stderr and stdout are independent streams. Await the matching metric
64
+ // outside the measured round trip before dispatching the next request.
65
+ const deadline = performance.now() + 1000
66
+ while (this.instrumented && !this.events.some(event => event.event === 'latency_metric') && performance.now() < deadline) await delay(5)
67
+ sample.metrics = this.events.filter((event): event is RequestMetric => event.event === 'latency_metric')
68
+ sample.tree = this.events.filter((event): event is TreeMetric => event.event === 'ui_tree_metric')
69
+ const text = (response.content as { type: string; text?: string }[]).find(item => item.type === 'text')?.text
70
+ const data = JSON.parse(text ?? '{}')
71
+ sample.success = !response.isError && data.success !== false && !data.error && data.status !== 'timeout' && validObservation(tool, data, response.content, this.deviceId)
72
+ if (!sample.success) {
73
+ sample.failure = 'TOOL_OUTCOME_FAILED'
74
+ // Only fixture-generated step IDs and enum-style codes may be saved.
75
+ const step = typeof data.stopped_at_step_id === 'string' ? data.stopped_at_step_id : ''
76
+ if (/^(launch|tap-\d+|assert-\d+)$/.test(step)) sample.failed_step = step
77
+ const failed = Array.isArray(data.steps) ? data.steps.find((value: Record<string, unknown>) => value.id === step) : undefined
78
+ const code = data.failure_code ?? object(object(failed).result).failure_code ?? object(data.error).code
79
+ if (typeof code === 'string' && /^[A-Z][A-Z_]{0,63}$/.test(code)) sample.failure_code = code
80
+ throw new Error(sample.failure)
81
+ }
82
+ if (this.instrumented && (sample.metrics.length !== 1 || sample.metrics[0].tool !== tool)) {
83
+ sample.success = false; sample.failure = 'METRIC_CORRELATION_FAILED'
84
+ throw new UnsafeMeasurement(sample.failure)
85
+ }
86
+ return data as Record<string, unknown>
87
+ } catch (error) {
88
+ if (!sample.elapsed_ms) sample.elapsed_ms = performance.now() - started
89
+ sample.metrics = this.events.filter((event): event is RequestMetric => event.event === 'latency_metric')
90
+ sample.tree = this.events.filter((event): event is TreeMetric => event.event === 'ui_tree_metric')
91
+ if (!sample.failure) {
92
+ sample.failure = this.interrupted.signal.aborted ? 'INTERRUPTED' : 'TRANSPORT_OR_RESPONSE_FAILED'
93
+ throw new UnsafeMeasurement(sample.failure)
94
+ }
95
+ throw error
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,149 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
3
+ import { BenchmarkSession, UnsafeMeasurement } from './end-to-end-session.js'
4
+ import { cleanupFixture, command, environment, fixturePresent, treeHash } from './end-to-end-environment.js'
5
+ import { summarizeAttempts, observationOverhead, type Attempt } from './end-to-end-report.js'
6
+
7
+ const deviceId = process.env.LATENCY_DEVICE_ID
8
+ if (!deviceId || !deviceId.startsWith('emulator-')) throw new Error('Set LATENCY_DEVICE_ID to an explicit Android emulator serial')
9
+ const adb = (...args: string[]) => command('adb', ['-s', deviceId, ...args])
10
+ const appId = 'dev.mobiledebugmcp.latencyfixture'
11
+ const smoke = process.argv.includes('--smoke')
12
+ const rounds = smoke ? 1 : 13
13
+ const warmups = smoke ? 0 : 3
14
+ const cases = ['standalone', 'full-debug', 'full-compact', 'light-debug', 'light-compact',
15
+ 'screenshot-on', 'screenshot-off', 'tree-on', 'tree-off', 'snapshot-on', 'snapshot-off']
16
+ const flow = [['saved-session', 'idle-detail'], ['activate', 'active-detail'], ['home', 'home-screen'],
17
+ ['saved-session', 'active-detail'], ['deactivate', 'idle-detail'], ['stop', 'stopped']]
18
+ const selector = (id: string) => ({ accessibility_id: id })
19
+ const controls = { actionTimeoutMs: 30000, verificationTimeoutMs: 60000 }
20
+ const attempts: Attempt[] = []
21
+ const phases: { phase: string; elapsed_ms: number; success: boolean }[] = []
22
+ const sessions = new Map<boolean, BenchmarkSession>()
23
+ const cleanup: { server?: boolean; fixture?: boolean; removed_install?: boolean } = {}
24
+ let metadata: ReturnType<typeof environment> | undefined
25
+ let interrupted = false
26
+ let fatal: string | undefined
27
+ let fixtureExisted = false
28
+ let installAttempted = false
29
+ let sourceStable: boolean | undefined
30
+ const startedAt = new Date().toISOString()
31
+ const root = 'docs/benchmarks/end-to-end-latency'
32
+ const reportName = smoke ? 'smoke' : 'baseline'
33
+ async function phase<T>(name: string, operation: () => T | Promise<T>) {
34
+ const start = performance.now()
35
+ let success = false
36
+ try { const result = await operation(); success = true; return result }
37
+ finally { phases.push({ phase: name, elapsed_ms: performance.now() - start, success }) }
38
+ }
39
+ function save() {
40
+ mkdirSync(root, { recursive: true })
41
+ const summaries = summarizeAttempts(attempts)
42
+ const report = { status: fatal ? 'stopped' : interrupted ? 'interrupted' : attempts.length === rounds * cases.length ? 'finished' : 'in_progress',
43
+ performance_evidence: !smoke && sourceStable !== false, smoke, warmups, measured_attempts_per_case: smoke ? 1 : 10,
44
+ started_at: startedAt, source_and_build_unchanged: sourceStable,
45
+ planned_cases: cases, unrun_attempts: rounds * cases.length - attempts.length,
46
+ outcome: fatal || interrupted || attempts.some(value => !value.success) || cleanup.server === false || cleanup.fixture === false ? 'fail' : cleanup.server && cleanup.fixture ? 'pass' : 'pending',
47
+ invocation: `LATENCY_DEVICE_ID=[emulator] MCP_ADB_TIMEOUT=${process.env.MCP_ADB_TIMEOUT ?? '20000'} npx tsx test/device/automated/latency/end-to-end.ts${smoke ? ' --smoke' : ''}`,
48
+ metadata, phases, fatal, cleanup, summaries, observation_overhead: observationOverhead(attempts), attempts }
49
+ writeFileSync(`${root}/${reportName}.json`, JSON.stringify(report, null, 2) + '\n')
50
+ const rows = Object.entries(summaries).map(([name, value]) => `| ${name} | ${value.total_ms?.count ?? 0}/${value.attempts} | ${value.total_ms?.p50.toFixed(1) ?? 'n/a'} | ${value.total_ms?.p95.toFixed(1) ?? 'n/a'} | ${value.failures} | ${value.warmup_failures} |`)
51
+ writeFileSync(`${root}/${reportName}.md`, `# End-to-end latency ${smoke ? 'smoke (not performance evidence)' : 'baseline'}\n\nStatus: ${report.status}. Build: ${metadata?.build_sha256 ?? 'unavailable'}.\n\n| Case | Successful/measured | p50 ms | p95 ms | Failures | Warm-up failures |\n| --- | ---: | ---: | ---: | ---: | ---: |\n${rows.join('\n')}\n\nSee ${reportName}.json for exact source/build hashes, environment, phase durations, attempt order, inclusive components and failure records. Percentiles use the successful measured count shown for each case. With 1–19 successful samples, nearest-rank p95 is the sample maximum. Zero successful samples yield no percentiles. Failed attempts are excluded from percentiles, retained in raw data, and fail the run.\n\nIntervals can overlap; component sums are not an additive latency budget. Client-minus-handler includes scheduling, transport and uninstrumented work. Handler residual excludes the union of recorded spans. Tree acquisition/parse/retry events are separate nested diagnostics. Application work inside requests remains included. Startup, fixture builds/resets and external validation are excluded from steady-state round trips. SDK wire serialization is not isolated.\n`)
52
+ }
53
+ async function reset(session: BenchmarkSession, observation: boolean) {
54
+ adb('shell', 'am', 'force-stop', appId)
55
+ if (!adb('shell', 'pm', 'clear', appId).includes('Success')) throw new Error('RESET_FAILED')
56
+ if (observation) {
57
+ adb('shell', 'am', 'start', '-W', '-n', `${appId}/.MainActivity`)
58
+ await session.call('expect_element_visible', { selector: selector('home-screen'), timeout_ms: 30000, poll_interval_ms: 100 }, [])
59
+ }
60
+ }
61
+ async function journey(session: BenchmarkSession, mode: string, attempt: Attempt) {
62
+ if (mode === 'standalone') {
63
+ await session.call('start_app', { appId, ...controls }, attempt.requests)
64
+ for (const [target, expected] of flow) {
65
+ const found = await session.call('wait_for_ui', { selector: selector(target), condition: 'clickable', timeout_ms: 10000, poll_interval_ms: 100 }, attempt.requests)
66
+ const element = found.element as { elementId?: string } | undefined
67
+ if (!element?.elementId) throw new Error('TARGET_HANDLE_MISSING')
68
+ await session.call('tap_element', { elementId: element.elementId, ...controls }, attempt.requests)
69
+ await session.call('expect_element_visible', { selector: selector(expected), timeout_ms: 30000, poll_interval_ms: 100 }, attempt.requests)
70
+ }
71
+ return
72
+ }
73
+ const [verificationMode, responseMode] = mode.split('-')
74
+ const steps: Record<string, unknown>[] = [{ id: 'launch', type: 'start_app', appId }]
75
+ flow.forEach(([target, expected], index) => {
76
+ steps.push({ id: `tap-${index}`, type: 'tap', selector: selector(target), waitFor: { condition: 'clickable', timeoutMs: 10000, pollIntervalMs: 100 } })
77
+ steps.push({ id: `assert-${index}`, type: 'assert', assertion: { kind: 'element_visible', selector: selector(expected) }, timeoutMs: 30000, pollIntervalMs: 100 })
78
+ })
79
+ await session.call('run_journey', { defaults: { verificationMode, ...controls }, responseMode, steps }, attempt.requests)
80
+ }
81
+ async function attempt(name: string, iteration: number) {
82
+ if (interrupted) throw new UnsafeMeasurement('INTERRUPTED')
83
+ const instrumented = !name.endsWith('-off')
84
+ const session = sessions.get(instrumented)!
85
+ const observation = /^(screenshot|tree|snapshot)-/.test(name)
86
+ const value: Attempt = { case: name, iteration, warmup: iteration < warmups, success: false, setup_ms: 0, requests: [] }
87
+ attempts.push(value)
88
+ const start = performance.now()
89
+ try {
90
+ await reset(session, observation)
91
+ value.setup_ms = performance.now() - start
92
+ if (!observation) await journey(session, name, value)
93
+ else {
94
+ const tool = name.startsWith('screenshot') ? 'capture_screenshot' : name.startsWith('tree') ? 'get_ui_tree' : 'capture_debug_snapshot'
95
+ await session.call(tool, tool === 'capture_debug_snapshot' ? { includeScreenshot: false, includeLogs: false } : {}, value.requests)
96
+ }
97
+ value.success = true
98
+ } catch (error) {
99
+ if (!value.setup_ms) value.setup_ms = performance.now() - start
100
+ value.failure = error instanceof UnsafeMeasurement ? error.message : value.requests.at(-1)?.failure ?? 'SETUP_OR_ASSERTION_FAILED'
101
+ if (error instanceof UnsafeMeasurement || !value.requests.length) throw new UnsafeMeasurement(value.failure)
102
+ } finally { save() }
103
+ }
104
+ async function main() {
105
+ const signal = () => { interrupted = true; for (const session of sessions.values()) session.abort() }
106
+ process.on('SIGINT', signal); process.on('SIGTERM', signal)
107
+ try {
108
+ if (adb('get-state') !== 'device' || adb('shell', 'getprop', 'ro.kernel.qemu') !== '1') throw new Error('EMULATOR_REQUIRED')
109
+ fixtureExisted = fixturePresent(adb, appId)
110
+ const apk = await phase('fixture_build', buildFixture)
111
+ metadata = environment(deviceId!, apk)
112
+ installAttempted = true
113
+ await phase('fixture_install', () => adb('install', '-r', apk))
114
+ for (const enabled of [true, false]) {
115
+ const session = new BenchmarkSession(deviceId!, enabled)
116
+ sessions.set(enabled, session)
117
+ await phase(`server_startup_metrics_${enabled}`, () => session.connect())
118
+ }
119
+ for (let round = 0; round < rounds; round++) {
120
+ const order = round % 2 ? [...cases].reverse() : cases
121
+ for (const name of order) {
122
+ console.log(`${smoke ? 'smoke' : `round ${round + 1}/${rounds}`} ${name}`)
123
+ await attempt(name, round)
124
+ }
125
+ }
126
+ } catch (error) {
127
+ fatal = error instanceof UnsafeMeasurement ? error.message : 'PREREQUISITE_OR_CLEANUP_FAILED'
128
+ process.exitCode = 1
129
+ } finally {
130
+ cleanup.server = true
131
+ for (const session of sessions.values()) {
132
+ try { await session.close(); cleanup.server &&= session.transport.cleanupVerified }
133
+ catch { cleanup.server = false }
134
+ }
135
+ try {
136
+ if (installAttempted && cleanupFixture(adb, appId, !fixtureExisted)) cleanup.removed_install = true
137
+ cleanup.fixture = true
138
+ } catch { cleanup.fixture = false }
139
+ if (!cleanup.server || !cleanup.fixture || interrupted || attempts.some(value => !value.success)) process.exitCode = 1
140
+ if (!cleanup.server || !cleanup.fixture) fatal = 'CLEANUP_FAILED'
141
+ if (metadata) {
142
+ sourceStable = metadata.source_sha256 === treeHash('src') && metadata.build_sha256 === treeHash('dist') && metadata.harness_sha256 === treeHash('test/device/automated/latency')
143
+ if (!sourceStable) { fatal = 'MEASURED_SOURCE_CHANGED'; process.exitCode = 1 }
144
+ }
145
+ save()
146
+ process.off('SIGINT', signal); process.off('SIGTERM', signal)
147
+ }
148
+ }
149
+ main().catch(() => { console.error('Benchmark failed; inspect timing report'); process.exitCode = 1 })
@@ -0,0 +1,81 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
2
+ import { setTimeout as delay } from 'node:timers/promises'
3
+ import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js'
4
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
5
+ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
6
+
7
+ // A dedicated POSIX process group bounds cleanup to this benchmark's server
8
+ // and its children. The SDK client and stdio wire protocol are unchanged.
9
+ export class OwnedTransport implements Transport {
10
+ onclose?: () => void
11
+ onerror?: (error: Error) => void
12
+ onmessage?: (message: JSONRPCMessage) => void
13
+ private child?: ChildProcessWithoutNullStreams
14
+ private buffer = new ReadBuffer({ maxBufferSize: 64 * 1024 * 1024 })
15
+ private closed?: Promise<void>
16
+ private closing?: Promise<void>
17
+ cleanupVerified = false
18
+ constructor(private env: NodeJS.ProcessEnv, private stderr: (chunk: string) => void,
19
+ private args = ['dist/server.js']) {}
20
+
21
+ async start() {
22
+ if (process.platform === 'win32') throw new Error('POSIX benchmark transport required')
23
+ if (this.child) throw new Error('Already started')
24
+ this.child = spawn(process.execPath, this.args, { env: this.env, detached: true, stdio: 'pipe' })
25
+ this.closed = new Promise(resolve => this.child!.once('close', () => { this.onclose?.(); resolve() }))
26
+ this.child.on('error', error => this.onerror?.(error))
27
+ this.child.stdin.on('error', error => this.onerror?.(error))
28
+ this.child.stderr.on('data', chunk => this.stderr(String(chunk)))
29
+ this.child.stdout.on('data', chunk => {
30
+ try {
31
+ this.buffer.append(chunk)
32
+ let message
33
+ while ((message = this.buffer.readMessage()) !== null) this.onmessage?.(message)
34
+ } catch (error) { this.onerror?.(error as Error) }
35
+ })
36
+ await new Promise<void>((resolve, reject) => { this.child!.once('spawn', resolve); this.child!.once('error', reject) })
37
+ }
38
+
39
+ async send(message: JSONRPCMessage) {
40
+ if (!this.child) throw new Error('Not started')
41
+ await new Promise<void>((resolve, reject) => this.child!.stdin.write(serializeMessage(message), error => error ? reject(error) : resolve()))
42
+ }
43
+
44
+ private groupExists() {
45
+ if (!this.child?.pid) return false
46
+ try { process.kill(-this.child.pid, 0); return true }
47
+ catch (error) {
48
+ const code = (error as NodeJS.ErrnoException).code
49
+ if (code === 'ESRCH') return false
50
+ // Darwin can return EPERM while a killed group is being reaped. It is
51
+ // still unconfirmed: retry until ESRCH, never count EPERM as absence.
52
+ if (code === 'EPERM') return true
53
+ throw error
54
+ }
55
+ }
56
+
57
+ private signal(signal: NodeJS.Signals) {
58
+ if (!this.child?.pid) return
59
+ try { process.kill(-this.child.pid, signal) }
60
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
61
+ }
62
+
63
+ close() {
64
+ this.closing ??= this.cleanup()
65
+ return this.closing
66
+ }
67
+
68
+ private async cleanup() {
69
+ if (!this.child) { this.cleanupVerified = true; return }
70
+ this.child.stdin.end()
71
+ await Promise.race([this.closed, delay(1000)])
72
+ if (this.groupExists()) this.signal('SIGTERM')
73
+ await Promise.race([this.closed, delay(1000)])
74
+ if (this.groupExists()) this.signal('SIGKILL')
75
+ const deadline = performance.now() + 3000
76
+ while (this.groupExists() && performance.now() < deadline) await delay(50)
77
+ this.cleanupVerified = !this.groupExists()
78
+ this.buffer.clear()
79
+ if (!this.cleanupVerified) throw new Error('Owned process group did not exit')
80
+ }
81
+ }
@@ -0,0 +1,143 @@
1
+ import { writeFileSync } from 'node:fs'
2
+ import { dirname, isAbsolute } from 'node:path'
3
+ import assert from 'node:assert/strict'
4
+ import { computeScreenFingerprint } from '../../../../src/utils/ui/index.js'
5
+ import type { CaptureDebugSnapshotRawResponse, GetCurrentScreenResponse } from '../../../../src/types.js'
6
+ import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
7
+ import { BenchmarkSession, UnsafeMeasurement } from './end-to-end-session.js'
8
+ import { cleanupFixture, command, environment, fixturePresent, treeHash } from './end-to-end-environment.js'
9
+ import { summarizeAttempts, type Attempt } from './end-to-end-report.js'
10
+
11
+ const device = process.env.LATENCY_DEVICE_ID ?? ''
12
+ const baseline = process.env.SNAPSHOT_BASELINE_SERVER ?? ''
13
+ const baselineSource = process.env.SNAPSHOT_BASELINE_SOURCE_SHA ?? ''
14
+ assert.match(device, /^emulator-\d+$/)
15
+ assert.ok(isAbsolute(baseline) && /^[a-f0-9]{64}$/.test(baselineSource), 'Provide the frozen baseline server path and source hash')
16
+ const adb = (...args: string[]) => command('adb', ['-s', device, ...args])
17
+ const app = 'dev.mobiledebugmcp.latencyfixture'
18
+ const fixtureActivity = `${app}.MainActivity`
19
+ const options = { includeScreenshot: false, includeLogs: false }
20
+ const attempts: Attempt[] = []
21
+ const sessions = {
22
+ baseline: new BenchmarkSession(device, true, baseline),
23
+ candidate: new BenchmarkSession(device, true)
24
+ }
25
+ type Build = keyof typeof sessions
26
+ let installAttempted = false, launched = false, interrupted = false
27
+ let fatal: string | undefined, metadata: ReturnType<typeof environment> | undefined
28
+ let fixtureSha: string | undefined
29
+ let nativeFreshness = false
30
+ const baselineHash = treeHash(dirname(baseline))
31
+ const cleanup = { servers: false, fixture: false }
32
+
33
+ async function checkFixtureActivity(session: BenchmarkSession) {
34
+ const screen = await session.call('get_current_screen', {}, [])
35
+ assert.equal(screen.package, app)
36
+ assert.equal(screen.activity, fixtureActivity)
37
+ }
38
+
39
+ function verifyFingerprint(raw: CaptureDebugSnapshotRawResponse) {
40
+ const tree = raw.ui_tree
41
+ assert.ok(tree && tree.device.id === device && tree.snapshot_revision)
42
+ assert.equal(raw.snapshot_revision, tree.snapshot_revision)
43
+ assert.equal(raw.captured_at_ms, tree.captured_at_ms)
44
+ assert.match(raw.fingerprint ?? '', /^[a-f0-9]{64}$/)
45
+ const current: GetCurrentScreenResponse = { device: tree.device, activity: fixtureActivity, package: app, shortActivity: '' }
46
+ assert.equal(raw.fingerprint, computeScreenFingerprint(tree, current, 'android').fingerprint)
47
+ }
48
+
49
+ async function sample(build: Build, iteration: number) {
50
+ if (interrupted) throw new UnsafeMeasurement('INTERRUPTED')
51
+ const attempt: Attempt = { case: build, iteration, warmup: iteration < 3, success: false, setup_ms: 0, requests: [] }
52
+ attempts.push(attempt)
53
+ try {
54
+ await checkFixtureActivity(sessions[build])
55
+ const data = await sessions[build].call('capture_debug_snapshot', options, attempt.requests)
56
+ const raw = data.raw as CaptureDebugSnapshotRawResponse
57
+ assert.ok(raw.ui_tree?.elements.some(element => element.contentDescription === 'home-screen' || element.text === 'home-screen'))
58
+ await checkFixtureActivity(sessions[build])
59
+ if (build === 'candidate') {
60
+ verifyFingerprint(raw)
61
+ assert.equal(attempt.requests[0].tree.filter(event => event.type === 'acquisition_completed').length, 1)
62
+ }
63
+ attempt.success = true
64
+ } catch (error) {
65
+ attempt.failure = attempt.requests[0]?.failure ?? 'SNAPSHOT_ASSERTION_FAILED'
66
+ if (error instanceof UnsafeMeasurement) throw error
67
+ }
68
+ }
69
+
70
+ async function checkFreshness() {
71
+ const candidate = sessions.candidate
72
+ const before = (await candidate.call('capture_debug_snapshot', options, [])).raw as CaptureDebugSnapshotRawResponse
73
+ await candidate.call('run_journey', { steps: [
74
+ { id: 'open', type: 'tap', selector: { accessibility_id: 'saved-session' } },
75
+ { id: 'check', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'idle-detail' } }, timeoutMs: 15000 }
76
+ ], defaults: { verificationMode: 'light' }, responseMode: 'compact' }, [])
77
+ await checkFixtureActivity(candidate)
78
+ const after = (await candidate.call('capture_debug_snapshot', options, [])).raw as CaptureDebugSnapshotRawResponse
79
+ await checkFixtureActivity(candidate)
80
+ verifyFingerprint(after)
81
+ assert.ok(after.ui_tree?.elements.some(element => element.contentDescription === 'idle-detail'))
82
+ assert.ok(after.snapshot_revision > before.snapshot_revision)
83
+ assert.notEqual(after.fingerprint, before.fingerprint)
84
+ nativeFreshness = true
85
+ }
86
+
87
+ async function main() {
88
+ const stop = () => { interrupted = true; for (const session of Object.values(sessions)) session.abort() }
89
+ process.on('SIGINT', stop); process.on('SIGTERM', stop)
90
+ try {
91
+ assert.equal(adb('get-state'), 'device')
92
+ assert.equal(adb('shell', 'getprop', 'ro.kernel.qemu'), '1')
93
+ const apk = buildFixture()
94
+ if (!fixturePresent(adb, app)) {
95
+ installAttempted = true
96
+ adb('install', apk)
97
+ }
98
+ const packagePath = adb('shell', 'pm', 'path', app).match(/^package:(.+)$/)?.[1]
99
+ assert.ok(packagePath)
100
+ fixtureSha = adb('shell', 'sha256sum', packagePath).split(/\s+/)[0]
101
+ assert.match(fixtureSha, /^[a-f0-9]{64}$/)
102
+ metadata = { ...environment(device, apk), fixture_sha256: fixtureSha }
103
+ adb('shell', 'am', 'force-stop', app)
104
+ launched = true
105
+ adb('shell', 'am', 'start', '-W', '-n', `${app}/.MainActivity`)
106
+ for (const session of Object.values(sessions)) await session.connect()
107
+ await sessions.baseline.call('expect_element_visible', { selector: { accessibility_id: 'home-screen' }, timeout_ms: 30000 }, [])
108
+ for (let iteration = 0; iteration < 13; iteration++) {
109
+ const order: Build[] = iteration % 2 ? ['candidate', 'baseline'] : ['baseline', 'candidate']
110
+ for (const build of order) {
111
+ console.log(`Round ${iteration + 1}/13 ${build}`)
112
+ await sample(build, iteration)
113
+ }
114
+ }
115
+ await checkFreshness()
116
+ } catch (error) { fatal = error instanceof UnsafeMeasurement ? error.message : 'SETUP_OR_FRESHNESS_FAILED' }
117
+ finally {
118
+ cleanup.servers = true
119
+ for (const session of Object.values(sessions)) {
120
+ try { await session.close(); cleanup.servers &&= session.transport.cleanupVerified }
121
+ catch { cleanup.servers = false }
122
+ }
123
+ try {
124
+ if (launched || installAttempted) cleanupFixture(adb, app, installAttempted)
125
+ cleanup.fixture = true
126
+ } catch { cleanup.fixture = false }
127
+ process.off('SIGINT', stop); process.off('SIGTERM', stop)
128
+ }
129
+ const summaries = summarizeAttempts(attempts)
130
+ const prior = summaries.baseline
131
+ const next = summaries.candidate
132
+ const sourceStable = metadata?.source_sha256 === treeHash('src') && metadata?.build_sha256 === treeHash('dist') &&
133
+ metadata?.harness_sha256 === treeHash('test/device/automated/latency') && baselineHash === treeHash(dirname(baseline))
134
+ const passed = !fatal && !interrupted && sourceStable && cleanup.servers && cleanup.fixture && nativeFreshness &&
135
+ attempts.length === 26 && attempts.every(attempt => attempt.success) && prior?.total_ms?.count === 10 && next?.total_ms?.count === 10 &&
136
+ next.physical_reads < prior.physical_reads && next.total_ms.p50 <= prior.total_ms.p50 && next.total_ms.p95 < prior.total_ms.p95
137
+ const report = { passed, fatal, interrupted, unrun_attempts: 26 - attempts.length, sourceStable, cleanup, nativeFreshness,
138
+ metadata, expected_fixture_activity: fixtureActivity, fixture_installed_sha256: fixtureSha, baseline: { source_sha256: baselineSource, build_sha256: baselineHash }, summaries, attempts }
139
+ writeFileSync('docs/benchmarks/end-to-end-latency/android-snapshot-pair.json', JSON.stringify(report, null, 2) + '\n')
140
+ process.exitCode = passed ? 0 : 1
141
+ }
142
+
143
+ main().catch(() => { console.error('Snapshot comparison could not write its report'); process.exitCode = 1 })