mobile-debug-mcp 0.32.0 → 0.32.2

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 (61) hide show
  1. package/README.md +1 -0
  2. package/dist/observe/android-tree-bridge.js +202 -0
  3. package/dist/observe/android.js +49 -15
  4. package/dist/observe/ios.js +45 -29
  5. package/dist/observe/performance-metrics.js +124 -0
  6. package/dist/server/tool-handlers.js +31 -9
  7. package/dist/server-core.js +1 -1
  8. package/dist/utils/exec.js +15 -4
  9. package/docs/CHANGELOG.md +6 -0
  10. package/docs/ROADMAP.md +9 -0
  11. package/docs/benchmarks/ui-tree-performance/README.md +58 -0
  12. package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
  13. package/docs/benchmarks/ui-tree-performance/bridge-probe.json +29 -0
  14. package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
  15. package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
  16. package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
  17. package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
  18. package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
  19. package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
  20. package/docs/tools/observe.md +2 -0
  21. package/package.json +1 -1
  22. package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
  23. package/src/observe/android-tree-bridge.ts +178 -0
  24. package/src/observe/android.ts +44 -17
  25. package/src/observe/ios.ts +45 -36
  26. package/src/observe/performance-metrics.ts +125 -0
  27. package/src/server/tool-handlers.ts +34 -10
  28. package/src/server-core.ts +1 -1
  29. package/src/utils/exec.ts +10 -4
  30. package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
  31. package/test/device/automated/ui-tree-performance/bridge-probe.ts +197 -0
  32. package/test/fixtures/latency-journey-app/AndroidManifest.xml +2 -1
  33. package/test/fixtures/latency-journey-app/README.md +19 -1
  34. package/test/fixtures/latency-journey-app/src/MainActivity.java +88 -1
  35. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
  36. package/test/fixtures/ui-tree-bridge/build.ts +34 -0
  37. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +126 -0
  38. package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
  39. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +95 -0
  40. package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
  41. package/test/unit/observe/performance_metrics.test.ts +40 -0
  42. package/test/unit/server/operation_budget.test.ts +13 -0
  43. package/test/unit/server/run_journey.test.ts +30 -0
  44. /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
  45. /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
  46. /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
  47. /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
  48. /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
  49. /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
  50. /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
  51. /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
  52. /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
  53. /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
  54. /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
  55. /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
  56. /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
  57. /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
  58. /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
  59. /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
  60. /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
  61. /package/docs/{specs-deltas/018-interaction-latency-and-batched-journeys.md → specs/018-interaction-latency-and-batched-journeys/spec.md} +0 -0
@@ -0,0 +1,401 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { createServer, type Server } from 'node:http'
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import path from 'node:path'
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
7
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
8
+ import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
9
+ import { summarize } from '../latency/report.js'
10
+
11
+ const baselineRoot = process.env.UI_TREE_BASELINE_ROOT && path.resolve(process.env.UI_TREE_BASELINE_ROOT)
12
+ const candidateRoot = process.env.UI_TREE_CANDIDATE_ROOT && path.resolve(process.env.UI_TREE_CANDIDATE_ROOT)
13
+ const baselineOnly = process.argv.includes('--baseline-only')
14
+ const smokeOnly = process.argv.includes('--smoke')
15
+ const iterations = smokeOnly ? 1 : 13
16
+ const deviceId = process.env.LATENCY_DEVICE_ID
17
+ if (!baselineRoot || (!baselineOnly && !candidateRoot) || !deviceId) throw new Error('Set UI_TREE_BASELINE_ROOT and LATENCY_DEVICE_ID; paired runs also require UI_TREE_CANDIDATE_ROOT')
18
+ if (!baselineOnly && baselineRoot === candidateRoot) throw new Error('Baseline and candidate roots must be different')
19
+
20
+ const appId = 'dev.mobiledebugmcp.latencyfixture'
21
+ const activity = `${appId}/.MainActivity`
22
+ const adb = (...args: string[]) => execFileSync('adb', ['-s', deviceId!, ...args], { encoding: 'utf8', timeout: 30000 }).trim()
23
+ const monotonicNs = () => process.hrtime.bigint().toString()
24
+ const elapsedMs = (start: bigint) => Number(process.hrtime.bigint() - start) / 1e6
25
+ const selector = (accessibility_id: string) => ({ accessibility_id })
26
+ const journeySteps: Record<string, any>[] = [{ id: 'launch', type: 'start_app', appId }]
27
+ const staticFlow = [
28
+ ['saved-session', 'idle-detail'], ['activate', 'active-detail'], ['home', 'home-screen'],
29
+ ['saved-session', 'active-detail'], ['deactivate', 'idle-detail'], ['stop', 'stopped']
30
+ ]
31
+ staticFlow.forEach(([target, expected], index) => {
32
+ journeySteps.push({ id: `tap-${index}`, type: 'tap', selector: selector(target), waitFor: { condition: 'clickable', timeoutMs: 10000, pollIntervalMs: 100 } })
33
+ journeySteps.push({ id: `assert-${index}`, type: 'assert', assertion: { kind: 'element_visible', selector: selector(expected) }, timeoutMs: 10000, pollIntervalMs: 100 })
34
+ })
35
+
36
+ interface MetricEvent { event?: string; type?: string; host_monotonic_ns?: string; [key: string]: unknown }
37
+ interface HarnessClient { client: Client; events: MetricEvent[]; close: () => Promise<void> }
38
+
39
+ function validateRoot(root: string) {
40
+ const serverPath = path.join(root, 'dist/server.js')
41
+ const packagePath = path.join(root, 'package.json')
42
+ if (!readFileSync(packagePath, 'utf8') || !readFileSync(serverPath, 'utf8')) throw new Error(`Missing built server or package metadata in ${root}`)
43
+ const status = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim()
44
+ if (status) throw new Error(`Benchmark root must be clean before device reset: ${root}`)
45
+ return { commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), version: JSON.parse(readFileSync(packagePath, 'utf8')).version }
46
+ }
47
+
48
+ function safeFailure(error: unknown) {
49
+ if (!(error instanceof Error)) return 'unknown_error'
50
+ const safePrefixes = [
51
+ 'run_journey failed (',
52
+ 'find_element failed (',
53
+ 'get_ui_tree failed (',
54
+ 'Static journey returned success=',
55
+ 'Network fixture did not expose loading-only state for ',
56
+ 'Network fixture never exposed ready/value for ',
57
+ 'Network assertion used a tree read started before the response for ',
58
+ 'Fixture request timed out for ',
59
+ 'Fixture response was not sent for '
60
+ ]
61
+ return safePrefixes.some((prefix) => error.message.startsWith(prefix)) ? error.message : error.name
62
+ }
63
+
64
+ async function startClient(root: string): Promise<HarnessClient> {
65
+ const events: MetricEvent[] = []
66
+ const transport = new StdioClientTransport({
67
+ command: process.execPath,
68
+ args: [path.join(root, 'dist/server.js')],
69
+ cwd: root,
70
+ env: { ...process.env, MOBILE_DEBUG_MCP_UI_TREE_METRICS: '1' },
71
+ stderr: 'pipe'
72
+ })
73
+ let pending = ''
74
+ transport.stderr?.on('data', (chunk) => {
75
+ pending += chunk.toString()
76
+ const lines = pending.split(/\r?\n/)
77
+ pending = lines.pop() ?? ''
78
+ for (const line of lines) {
79
+ try {
80
+ const event = JSON.parse(line)
81
+ if (event.event === 'ui_tree_metric') events.push(event)
82
+ } catch { /* Server diagnostics are not benchmark measurement records. */ }
83
+ }
84
+ })
85
+ const client = new Client({ name: 'ui-tree-performance', version: '1.0' })
86
+ await client.connect(transport)
87
+ return { client, events, close: () => client.close() }
88
+ }
89
+
90
+ async function call(harness: HarnessClient, name: string, args: Record<string, unknown>) {
91
+ const startedNs = monotonicNs()
92
+ const eventsStart = harness.events.length
93
+ const started = process.hrtime.bigint()
94
+ const response: any = await harness.client.callTool({ name, arguments: { platform: 'android', deviceId, ...args } }, undefined, { timeout: 300000 })
95
+ const elapsed = elapsedMs(started)
96
+ const text = response.content?.find((item: any) => item.type === 'text')?.text
97
+ let data: any
98
+ try { data = JSON.parse(text ?? '{}') } catch { throw new Error(`${name} returned a non-JSON response`) }
99
+ if (response.isError || data.error || data.success === false || data.status === 'timeout') {
100
+ const failedStep = Array.isArray(args.steps) ? args.steps.find((step: any) => step.id === data.stopped_at_step_id) : undefined
101
+ const selectorId = failedStep?.selector?.accessibility_id ?? failedStep?.assertion?.selector?.accessibility_id
102
+ const stepDetail = failedStep ? ` step=${failedStep.id}${selectorId ? ` selector=${selectorId}` : ''}` : ''
103
+ throw new Error(`${name} failed (${data.failure_code ?? data.error?.code ?? 'tool_error'})${stepDetail}`)
104
+ }
105
+ return { data, elapsed, started_ns: startedNs, events: harness.events.slice(eventsStart) }
106
+ }
107
+
108
+ function resetFixture(apk: string) {
109
+ adb('shell', 'am', 'force-stop', appId)
110
+ try { adb('shell', 'pm', 'clear', appId) } catch { /* First install has no app data. */ }
111
+ try {
112
+ adb('install', '-r', apk)
113
+ } catch (error) {
114
+ if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE')) throw error
115
+ adb('uninstall', appId)
116
+ adb('install', '-r', apk)
117
+ }
118
+ }
119
+
120
+ function containsElement(tree: any, id: string) {
121
+ return Array.isArray(tree?.elements) && tree.elements.some((element: any) => element.contentDescription === id || element.resourceId === id || element.text === id)
122
+ }
123
+
124
+ async function runStatic(harness: HarnessClient, apk: string) {
125
+ resetFixture(apk)
126
+ adb('shell', 'am', 'start', '-n', activity)
127
+ const diagnostic = await call(harness, 'find_element', { query: 'saved-session', exact: true, timeoutMs: 10000 })
128
+ adb('shell', 'am', 'force-stop', appId)
129
+ adb('shell', 'pm', 'clear', appId)
130
+ const result = await call(harness, 'run_journey', {
131
+ responseMode: 'compact',
132
+ defaults: { verificationMode: 'light', actionTimeoutMs: 30000, verificationTimeoutMs: 3000 },
133
+ steps: journeySteps
134
+ })
135
+ if (result.data.success !== true) throw new Error(`Static journey returned success=${result.data.success}`)
136
+ return {
137
+ elapsed_ms: result.elapsed,
138
+ started_ns: result.started_ns,
139
+ find_element_diagnostic: { elapsed_ms: diagnostic.elapsed, found: diagnostic.data.found },
140
+ request_sequence: [
141
+ { tool: 'find_element', phase: 'diagnostic' },
142
+ { tool: 'run_journey', steps: journeySteps.map((step) => ({ id: step.id, type: step.type })) }
143
+ ],
144
+ events: [...diagnostic.events, ...result.events],
145
+ steps: result.data.steps.map((step: any) => ({ id: step.id, status: step.status, success: step.success, failure_code: step.result?.failure_code }))
146
+ }
147
+ }
148
+
149
+ interface ControlledServer { server: Server; url: string; waitForRequest: () => Promise<void>; release: () => Promise<string> }
150
+
151
+ async function startControlledServer(delayMs: number, runId: string): Promise<ControlledServer> {
152
+ let requestSeen!: () => void
153
+ let releaseResponse!: () => void
154
+ let sendNs = ''
155
+ const requestPromise = new Promise<void>((resolve) => { requestSeen = resolve })
156
+ const releasePromise = new Promise<void>((resolve) => { releaseResponse = resolve })
157
+ const server = createServer(async (request, response) => {
158
+ const requestUrl = new URL(request.url ?? '/', 'http://localhost')
159
+ if (requestUrl.pathname !== '/data' || requestUrl.searchParams.get('run_id') !== runId) {
160
+ response.writeHead(404).end()
161
+ return
162
+ }
163
+ requestSeen()
164
+ await releasePromise
165
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
166
+ sendNs = monotonicNs()
167
+ response.writeHead(200, { 'content-type': 'text/plain', 'cache-control': 'no-store' }).end(`${runId}|value-${runId}`)
168
+ })
169
+ await new Promise<void>((resolve, reject) => {
170
+ server.once('error', reject)
171
+ server.listen(0, '0.0.0.0', () => resolve())
172
+ })
173
+ const address = server.address()
174
+ if (!address || typeof address === 'string') throw new Error('Could not bind local fixture server')
175
+ const waitForRequest = () => new Promise<void>((resolve, reject) => {
176
+ const timer = setTimeout(() => reject(new Error(`Fixture request timed out for ${runId}`)), 10000)
177
+ requestPromise.then(() => { clearTimeout(timer); resolve() })
178
+ })
179
+ return {
180
+ server,
181
+ url: `http://10.0.2.2:${address.port}`,
182
+ waitForRequest,
183
+ release: async () => {
184
+ await waitForRequest()
185
+ releaseResponse()
186
+ const deadline = Date.now() + delayMs + 15000
187
+ while (!sendNs && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
188
+ if (!sendNs) throw new Error(`Fixture response was not sent for ${runId}`)
189
+ return sendNs
190
+ }
191
+ }
192
+ }
193
+
194
+ async function runNetwork(harness: HarnessClient, apk: string, delayMs: number, runId: string) {
195
+ resetFixture(apk)
196
+ const server = await startControlledServer(delayMs, runId)
197
+ const allEventsStart = harness.events.length
198
+ try {
199
+ adb('shell', 'am', 'start', '-n', activity, '--es', 'networkBaseUrl', server.url, '--es', 'networkRunId', runId)
200
+ const started = process.hrtime.bigint()
201
+ const initial = await call(harness, 'get_ui_tree', { _metricsStepId: `network-loading-${runId}` })
202
+ if (!containsElement(initial.data, 'loading') || containsElement(initial.data, 'ready') || containsElement(initial.data, 'value')) {
203
+ throw new Error(`Network fixture did not expose loading-only state for ${runId}`)
204
+ }
205
+ const sendNs = await server.release()
206
+ const deadline = Date.now() + 15000
207
+ let finalTree: any = null
208
+ let finalReadEvents: MetricEvent[] = []
209
+ let finalReadStartNs = ''
210
+ while (Date.now() < deadline) {
211
+ const observation = await call(harness, 'get_ui_tree', { _metricsStepId: `network-post-response-${runId}` })
212
+ if (containsElement(observation.data, 'ready') && observation.data.elements?.some((element: any) => element.contentDescription === 'value' && element.text === `value-${runId}`)) {
213
+ finalTree = observation.data
214
+ finalReadEvents = observation.events
215
+ finalReadStartNs = observation.started_ns
216
+ break
217
+ }
218
+ }
219
+ if (!finalTree) throw new Error(`Network fixture never exposed ready/value for ${runId}`)
220
+ const physicalStarts = finalReadEvents.filter((event) => event.type === 'physical_read_started')
221
+ if (!physicalStarts.length || physicalStarts.some((event) => BigInt(String(event.host_monotonic_ns)) <= BigInt(sendNs))) {
222
+ throw new Error(`Network assertion used a tree read started before the response for ${runId}`)
223
+ }
224
+ return {
225
+ elapsed_ms: elapsedMs(started),
226
+ request_started_ns: initial.started_ns,
227
+ response_sent_ns: sendNs,
228
+ assertion_request_started_ns: finalReadStartNs,
229
+ request_sequence: [
230
+ { tool: 'get_ui_tree', step_id: `network-loading-${runId}` },
231
+ { tool: 'get_ui_tree', step_id: `network-post-response-${runId}`, attempts: finalReadEvents.filter((event) => event.type === 'physical_read_started').length }
232
+ ],
233
+ events: harness.events.slice(allEventsStart),
234
+ assertion_read_events: finalReadEvents
235
+ }
236
+ } finally {
237
+ server.server.closeAllConnections()
238
+ await new Promise<void>((resolve) => server.server.close(() => resolve()))
239
+ }
240
+ }
241
+
242
+ async function main() {
243
+ const roots: Record<string, { commit: string; version: string }> = { baseline: validateRoot(baselineRoot!) }
244
+ if (!baselineOnly) roots.candidate = validateRoot(candidateRoot!)
245
+ const apk = buildFixture()
246
+ const fixtureHash = createHash('sha256').update(readFileSync(apk)).digest('hex')
247
+ const baselineClient = await startClient(baselineRoot!)
248
+ const clients: Record<'baseline' | 'candidate', HarnessClient | undefined> = { baseline: baselineClient, candidate: undefined }
249
+ if (!baselineOnly) {
250
+ try { clients.candidate = await startClient(candidateRoot!) }
251
+ catch (error) { await baselineClient.close(); throw error }
252
+ }
253
+ const raw: any[] = []
254
+ try {
255
+ const runModes = async (kind: 'static' | 'network', delayMs?: number) => {
256
+ for (let iteration = 0; iteration < iterations; iteration++) {
257
+ const order: Array<'baseline' | 'candidate'> = baselineOnly ? ['baseline'] : iteration % 2 === 0 ? ['baseline', 'candidate'] : ['candidate', 'baseline']
258
+ for (const mode of order) {
259
+ const warmup = !smokeOnly && iteration < 3
260
+ const runId = `${kind}-${delayMs ?? 0}-${iteration}-${mode}-${Date.now()}`
261
+ const harness = clients[mode]
262
+ if (!harness) throw new Error(`Missing ${mode} MCP client`)
263
+ const eventsStart = harness.events.length
264
+ try {
265
+ const result = kind === 'static'
266
+ ? await runStatic(harness, apk)
267
+ : await runNetwork(harness, apk, delayMs!, runId)
268
+ raw.push({ mode, flow: kind, delay_ms: delayMs, iteration, warmup, success: true, ...result })
269
+ console.log(`${kind} ${delayMs ?? ''} ${mode} ${iteration + 1}/${iterations} PASS`)
270
+ } catch (error) {
271
+ const failure = safeFailure(error)
272
+ raw.push({ mode, flow: kind, delay_ms: delayMs, iteration, warmup, success: false, failure, events: harness.events.slice(eventsStart) })
273
+ console.error(`${kind} ${delayMs ?? ''} ${mode} ${iteration + 1}/${iterations} FAIL: ${failure}`)
274
+ }
275
+ }
276
+ }
277
+ }
278
+ await runModes('static')
279
+ await runModes('network', 100)
280
+ await runModes('network', 1500)
281
+ } finally {
282
+ await Promise.all(Object.values(clients).filter((harness): harness is HarnessClient => !!harness).map((harness) => harness.close()))
283
+ }
284
+
285
+ if (smokeOnly) {
286
+ if (raw.some((run) => !run.success)) process.exitCode = 1
287
+ return
288
+ }
289
+
290
+ const summarizeMode = (mode: 'baseline' | 'candidate', flow: string, delayMs?: number) => {
291
+ const measured = raw.filter((run) => run.mode === mode && run.flow === flow && run.delay_ms === delayMs && !run.warmup)
292
+ const successful = measured.filter((run) => run.success)
293
+ const actionSamples = successful.flatMap((run) => run.events.filter((event: MetricEvent) => event.type === 'action_observation'))
294
+ const acquisitions = successful.flatMap((run) => run.events.filter((event: MetricEvent) => event.type === 'acquisition_completed'))
295
+ const stageSamples = successful.flatMap((run) => run.events.filter((event: MetricEvent) => event.type === 'stage'))
296
+ const allEvents = measured.flatMap((run) => run.events ?? []) as MetricEvent[]
297
+ const groupKeys = [...new Set(allEvents.flatMap((event) => {
298
+ if (event.type === 'physical_read_started' || event.type === 'stage' || event.type === 'acquisition_completed') return [`${event.step_id ?? 'unattributed'}|${event.purpose ?? 'unspecified'}`]
299
+ if (event.type === 'observation_consumer') return [`${event.consumer_step_id ?? 'unattributed'}|${event.consumer_purpose ?? 'unspecified'}`]
300
+ return []
301
+ }))]
302
+ const acquisitionBreakdown = Object.fromEntries(groupKeys.map((key) => {
303
+ const [stepId, purpose] = key.split('|')
304
+ const events = allEvents.filter((event) => (event.step_id ?? 'unattributed') === stepId && (event.purpose ?? 'unspecified') === purpose)
305
+ const startedReads = events.filter((event) => event.type === 'physical_read_started')
306
+ const completedReads = events.filter((event) => event.type === 'physical_read_completed')
307
+ const invalidParseAttempts = events.filter((event) => event.type === 'stage' && event.stage === 'parse_ms' && event.success === false)
308
+ const failedReadAttempts = new Set([
309
+ ...completedReads.filter((event) => event.success === false).map((event) => `${event.acquisition_id}|${event.attempt_index}`),
310
+ ...invalidParseAttempts.map((event) => `${event.acquisition_id}|${event.attempt_index}`)
311
+ ])
312
+ const stageGroups = Object.fromEntries([...new Set(events.filter((event) => event.type === 'stage').map((event) => String(event.stage)))].map((stage) => [stage, summarize(events.filter((event) => event.type === 'stage' && event.stage === stage && typeof event.elapsed_ms === 'number').map((event) => Number(event.elapsed_ms)))]))
313
+ const reused = allEvents.filter((event) => event.type === 'observation_consumer' && (event.consumer_step_id ?? 'unattributed') === stepId && (event.consumer_purpose ?? 'unspecified') === purpose).reduce((sum, event) => sum + Number(event.reused_observations ?? 0), 0)
314
+ return [key, {
315
+ physical_tree_reads: startedReads.length,
316
+ failed_tree_reads: failedReadAttempts.size,
317
+ reused_observations: reused,
318
+ tree_read_ms: summarize(completedReads.filter((event) => typeof event.elapsed_ms === 'number').map((event) => Number(event.elapsed_ms))),
319
+ acquisition_ms: summarize(acquisitions.filter((event) => (event.step_id ?? 'unattributed') === stepId && (event.purpose ?? 'unspecified') === purpose).map((event) => Number(event.elapsed_ms))),
320
+ stages_ms: stageGroups
321
+ }]
322
+ }))
323
+ return {
324
+ measured_attempts: measured.length,
325
+ failures: measured.filter((run) => !run.success).length,
326
+ end_to_end_ms: summarize(successful.map((run) => run.elapsed_ms)),
327
+ action_observations: actionSamples,
328
+ acquisitions,
329
+ stage_samples: stageSamples,
330
+ acquisition_breakdown_by_step_purpose: acquisitionBreakdown,
331
+ target_resolution_ms: summarize(actionSamples.filter((event) => typeof event.target_resolution_ms === 'number').map((event) => Number(event.target_resolution_ms))),
332
+ dispatch_ms: summarize(actionSamples.filter((event) => typeof event.dispatch_ms === 'number').map((event) => Number(event.dispatch_ms))),
333
+ light_verification_elapsed_ms: summarize(actionSamples.filter((event) => typeof event.verification_elapsed_ms === 'number').map((event) => Number(event.verification_elapsed_ms))),
334
+ light_verification_unavailable_count: actionSamples.filter((event) => event.verification_outcome === 'unavailable').length,
335
+ find_element_ms: summarize(successful.filter((run) => typeof run.find_element_diagnostic?.elapsed_ms === 'number').map((run) => Number(run.find_element_diagnostic.elapsed_ms))),
336
+ find_element_not_found_count: successful.filter((run) => run.find_element_diagnostic?.found !== true).length
337
+ }
338
+ }
339
+ const modes: Record<string, any> = {
340
+ baseline: { static: summarizeMode('baseline', 'static'), network_100ms: summarizeMode('baseline', 'network', 100), network_1500ms: summarizeMode('baseline', 'network', 1500) }
341
+ }
342
+ if (!baselineOnly) modes.candidate = { static: summarizeMode('candidate', 'static'), network_100ms: summarizeMode('candidate', 'network', 100), network_1500ms: summarizeMode('candidate', 'network', 1500) }
343
+ const baselineSummary = modes.baseline as any
344
+ const candidateSummary = modes.candidate as any
345
+ const gates = baselineOnly ? undefined : {
346
+ static_p50_improvement: !!baselineSummary.static.end_to_end_ms && !!candidateSummary.static.end_to_end_ms && candidateSummary.static.end_to_end_ms.p50 <= baselineSummary.static.end_to_end_ms.p50 * 0.85,
347
+ static_p95_regression: !!baselineSummary.static.end_to_end_ms && !!candidateSummary.static.end_to_end_ms && candidateSummary.static.end_to_end_ms.p95 <= baselineSummary.static.end_to_end_ms.p95 * 1.05,
348
+ network_100ms_p95_regression: !!baselineSummary.network_100ms.end_to_end_ms && !!candidateSummary.network_100ms.end_to_end_ms && candidateSummary.network_100ms.end_to_end_ms.p95 <= baselineSummary.network_100ms.end_to_end_ms.p95 * 1.05,
349
+ network_1500ms_p95_regression: !!baselineSummary.network_1500ms.end_to_end_ms && !!candidateSummary.network_1500ms.end_to_end_ms && candidateSummary.network_1500ms.end_to_end_ms.p95 <= baselineSummary.network_1500ms.end_to_end_ms.p95 * 1.05,
350
+ zero_measured_failures: !raw.some((run) => !run.warmup && !run.success)
351
+ }
352
+ const report = {
353
+ created_at: new Date().toISOString(),
354
+ fixture_sha256: fixtureHash,
355
+ fixture_version: '1.0',
356
+ device_id: '[redacted]',
357
+ platform: 'android',
358
+ model: adb('shell', 'getprop', 'ro.product.model'),
359
+ os: adb('shell', 'getprop', 'ro.build.version.release'),
360
+ api: adb('shell', 'getprop', 'ro.build.version.sdk'),
361
+ emulator_image: adb('emu', 'avd', 'name').split('\n')[0].trim(),
362
+ display_size: adb('shell', 'wm', 'size'),
363
+ display_density: adb('shell', 'wm', 'density'),
364
+ host: { platform: process.platform, arch: process.arch, node: process.version },
365
+ command_timeout_ms: 30000,
366
+ request_timeout_ms: 300000,
367
+ warmup_runs_per_flow: 3,
368
+ measured_runs_per_flow: 10,
369
+ roots,
370
+ modes,
371
+ gates,
372
+ raw
373
+ }
374
+ const environment = {
375
+ model: report.model,
376
+ os: report.os,
377
+ api: report.api,
378
+ emulator_image: report.emulator_image,
379
+ display_size: report.display_size,
380
+ display_density: report.display_density,
381
+ host: report.host,
382
+ command_timeout_ms: report.command_timeout_ms,
383
+ request_timeout_ms: report.request_timeout_ms,
384
+ warmup_runs_per_flow: report.warmup_runs_per_flow,
385
+ measured_runs_per_flow: report.measured_runs_per_flow
386
+ }
387
+ const outputRoot = path.join(baselineOnly ? baselineRoot! : candidateRoot!, 'docs/benchmarks/ui-tree-performance')
388
+ mkdirSync(outputRoot, { recursive: true })
389
+ writeFileSync(path.join(outputRoot, 'baseline.json'), JSON.stringify({ commit: roots.baseline.commit, server_version: roots.baseline.version, fixture_sha256: fixtureHash, platform: report.platform, environment, modes: modes.baseline, raw: raw.filter((run) => run.mode === 'baseline') }, null, 2))
390
+ if (!baselineOnly) {
391
+ writeFileSync(path.join(outputRoot, 'comparison.json'), JSON.stringify(report, null, 2))
392
+ writeFileSync(path.join(outputRoot, 'candidate.json'), JSON.stringify({ commit: roots.candidate.commit, server_version: roots.candidate.version, fixture_sha256: fixtureHash, platform: report.platform, environment, modes: modes.candidate, raw: raw.filter((run) => run.mode === 'candidate') }, null, 2))
393
+ const rows = Object.entries(modes).flatMap(([mode, flows]: [string, any]) => Object.entries(flows).map(([flow, value]: [string, any]) => `| ${mode} | ${flow} | ${value.end_to_end_ms?.p50 ?? 'n/a'} | ${value.end_to_end_ms?.p95 ?? 'n/a'} | ${value.failures} |`))
394
+ writeFileSync(path.join(outputRoot, 'comparison.md'), `# UI tree observation performance\n\nFixture SHA-256: ${fixtureHash}. Android ${report.os}, API ${report.api}. Three warm-ups and ten measured attempts per flow and mode. Failed attempts are visible in the JSON reports and excluded from percentiles.\n\n| Mode | Flow | p50 ms | p95 ms | Failures |\n|---|---|---:|---:|---:|\n${rows.join('\n')}\n\n| Acceptance gate | Result |\n|---|---|\n${Object.entries(gates!).map(([gate, passed]) => `| ${gate} | ${passed ? 'pass' : 'fail'} |`).join('\n')}\n\nRaw per-attempt records include acquisition and consumer events, response-send timestamps, and assertion-read timestamps. Device serials and UI contents are not recorded.\n`)
395
+ if (Object.values(gates!).some((passed) => !passed)) process.exitCode = 1
396
+ } else if (raw.some((run) => !run.warmup && !run.success)) {
397
+ process.exitCode = 1
398
+ }
399
+ }
400
+
401
+ main().catch((error) => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,197 @@
1
+ import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
2
+ import { createServer } from 'node:http'
3
+ import { connect } from 'node:net'
4
+ import { mkdirSync, writeFileSync } from 'node:fs'
5
+ import path from 'node:path'
6
+ import { randomUUID } from 'node:crypto'
7
+ import { XMLParser } from 'fast-xml-parser'
8
+ import { buildBridge } from '../../../fixtures/ui-tree-bridge/build.js'
9
+ import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
10
+ import { summarize } from '../latency/report.js'
11
+
12
+ const deviceId = process.env.LATENCY_DEVICE_ID ?? ''
13
+ if (!deviceId) throw new Error('Set LATENCY_DEVICE_ID to one Android emulator')
14
+ const bridgePackage = 'dev.mobiledebugmcp.treebridge'
15
+ const fixturePackage = 'dev.mobiledebugmcp.latencyfixture'
16
+ const hostPort = 39020
17
+ const devicePort = 39019
18
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' })
19
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
20
+ const elapsedMs = (start: bigint) => Number(process.hrtime.bigint() - start) / 1e6
21
+ const adb = (...args: string[]) => execFileSync('adb', ['-s', deviceId!, ...args], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'pipe'] }).trim()
22
+
23
+ function install(apk: string, appId: string) {
24
+ try { adb('install', '-r', apk) }
25
+ catch (error) {
26
+ if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE')) throw error
27
+ adb('uninstall', appId)
28
+ adb('install', '-r', apk)
29
+ }
30
+ }
31
+
32
+ function nodes(xml: string): Array<Record<string, string>> {
33
+ const tree = parser.parse(xml)
34
+ const result: Array<Record<string, string>> = []
35
+ const visit = (item: any) => {
36
+ if (!item) return
37
+ if (Array.isArray(item)) return item.forEach(visit)
38
+ if (item['@_class']) result.push(item)
39
+ visit(item.node)
40
+ }
41
+ visit(tree.hierarchy)
42
+ return result
43
+ }
44
+
45
+ function bridgeRead(): Promise<{ xml: string; elapsed_ms: number; started_ns: bigint }> {
46
+ const started_ns = process.hrtime.bigint()
47
+ return new Promise((resolve, reject) => {
48
+ let output = ''
49
+ const socket = connect(hostPort, '127.0.0.1', () => socket.write('TREE\n'))
50
+ socket.setTimeout(10000, () => socket.destroy(new Error('Bridge read timed out')))
51
+ socket.on('data', (chunk) => { output += chunk.toString() })
52
+ socket.on('error', reject)
53
+ socket.on('end', () => {
54
+ if (!output.startsWith('OK\n') || !output.includes('<hierarchy')) return reject(new Error(`Bridge response failed: ${output.slice(0, 80)}`))
55
+ resolve({ xml: output.slice(3), elapsed_ms: elapsedMs(started_ns), started_ns })
56
+ })
57
+ })
58
+ }
59
+
60
+ async function readyBridge() {
61
+ const deadline = Date.now() + 10000
62
+ while (Date.now() < deadline) {
63
+ try { return await bridgeRead() } catch { await sleep(100) }
64
+ }
65
+ throw new Error('Bridge did not become ready')
66
+ }
67
+
68
+ async function stopBridge() {
69
+ await new Promise<void>((resolve, reject) => {
70
+ const socket = connect(hostPort, '127.0.0.1', () => socket.write('STOP\n'))
71
+ socket.setTimeout(5000, () => socket.destroy(new Error('Bridge stop timed out')))
72
+ socket.on('error', reject)
73
+ socket.on('data', (chunk) => {
74
+ if (chunk.toString().startsWith('OK\n')) {
75
+ socket.destroy()
76
+ resolve()
77
+ }
78
+ })
79
+ })
80
+ }
81
+
82
+ async function main() {
83
+ const bridgeApk = buildBridge()
84
+ const fixtureApk = buildFixture()
85
+ install(bridgeApk, bridgePackage)
86
+ install(fixtureApk, fixturePackage)
87
+ const runId = randomUUID()
88
+ let releaseResponse!: () => void
89
+ let requestSeen!: () => void
90
+ let responseSentNs = 0n
91
+ const requestPromise = new Promise<void>((resolve) => { requestSeen = resolve })
92
+ const releasePromise = new Promise<void>((resolve) => { releaseResponse = resolve })
93
+ const server = createServer(async (request, response) => {
94
+ const url = new URL(request.url ?? '/', 'http://localhost')
95
+ if (url.pathname !== '/data' || url.searchParams.get('run_id') !== runId) return response.writeHead(404).end()
96
+ requestSeen()
97
+ await releasePromise
98
+ await sleep(100)
99
+ responseSentNs = process.hrtime.bigint()
100
+ response.writeHead(200, { 'content-type': 'text/plain', 'cache-control': 'no-store' }).end(`${runId}|value-${runId}`)
101
+ })
102
+ await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '0.0.0.0', resolve) })
103
+ const address = server.address()
104
+ if (!address || typeof address === 'string') throw new Error('Fixture server did not bind')
105
+ const instrumentation: ChildProcess = spawn('adb', ['-s', deviceId, 'shell', 'am', 'instrument', '-w', `${bridgePackage}/.TreeInstrumentation`], { stdio: ['ignore', 'pipe', 'pipe'] })
106
+ let bridgeStarted = false
107
+ let forwarded = false
108
+ try {
109
+ adb('forward', `tcp:${hostPort}`, `tcp:${devicePort}`)
110
+ forwarded = true
111
+ await readyBridge()
112
+ bridgeStarted = true
113
+ adb('shell', 'am', 'force-stop', fixturePackage)
114
+ adb('shell', 'am', 'start', '-n', `${fixturePackage}/.MainActivity`, '--ez', 'silentTimer', 'true')
115
+ const initialDeadline = Date.now() + 5000
116
+ let initialTimer = false
117
+ while (Date.now() < initialDeadline) {
118
+ initialTimer = nodes((await bridgeRead()).xml).some((node) => node['@_content-desc'] === 'silent timer 0:00')
119
+ if (initialTimer) break
120
+ await sleep(50)
121
+ }
122
+ if (!initialTimer) throw new Error('Bridge did not observe the initial silent timer')
123
+ await sleep(2300)
124
+ const updatedTimer = nodes((await bridgeRead()).xml).some((node) => node['@_content-desc'] === 'silent timer 0:01')
125
+ if (!updatedTimer) throw new Error('Bridge reused a cached timer node after a visible update without an accessibility event')
126
+ adb('shell', 'am', 'force-stop', fixturePackage)
127
+ adb('shell', 'pm', 'clear', fixturePackage)
128
+ adb('shell', 'am', 'start', '-n', `${fixturePackage}/.MainActivity`, '--es', 'networkBaseUrl', `http://10.0.2.2:${address.port}`, '--es', 'networkRunId', runId)
129
+ await Promise.race([requestPromise, sleep(10000).then(() => { throw new Error('Fixture request timed out') })])
130
+ const loadingDeadline = Date.now() + 10000
131
+ let loadingOnly = false
132
+ while (Date.now() < loadingDeadline) {
133
+ const tree = nodes((await bridgeRead()).xml)
134
+ loadingOnly = tree.some((node) => node['@_content-desc'] === 'loading') && !tree.some((node) => node['@_content-desc'] === 'ready' || node['@_content-desc'] === 'value')
135
+ if (loadingOnly) break
136
+ await sleep(50)
137
+ }
138
+ if (!loadingOnly) throw new Error('Bridge did not observe loading-only state')
139
+ releaseResponse()
140
+ while (!responseSentNs) await sleep(10)
141
+ const readyDeadline = Date.now() + 10000
142
+ let readyRead: Awaited<ReturnType<typeof bridgeRead>> | undefined
143
+ while (Date.now() < readyDeadline) {
144
+ const read = await bridgeRead()
145
+ const tree = nodes(read.xml)
146
+ if (tree.some((node) => node['@_content-desc'] === 'ready') && tree.some((node) => node['@_content-desc'] === 'value' && node['@_text'] === `value-${runId}`)) {
147
+ readyRead = read
148
+ break
149
+ }
150
+ await sleep(50)
151
+ }
152
+ if (!readyRead || readyRead.started_ns <= responseSentNs) throw new Error('Bridge ready/value assertion was not based on a post-response read')
153
+ const bridgeSamples = []
154
+ for (let index = 0; index < 10; index++) bridgeSamples.push(await bridgeRead())
155
+ await stopBridge()
156
+ bridgeStarted = false
157
+ if (instrumentation.exitCode === null) await new Promise<void>((resolve) => instrumentation.once('close', () => resolve()))
158
+ adb('forward', '--remove', `tcp:${hostPort}`)
159
+ forwarded = false
160
+ const legacySamples: Array<{ xml: string; elapsed_ms: number }> = []
161
+ for (let index = 0; index < 10; index++) {
162
+ const started = process.hrtime.bigint()
163
+ // Android may still be releasing the instrumentation accessibility connection.
164
+ for (let retry = 0; ; retry++) {
165
+ try { adb('shell', 'uiautomator', 'dump', '/sdcard/ui.xml'); break }
166
+ catch (error) {
167
+ if (retry === 2) throw error
168
+ await sleep(500)
169
+ }
170
+ }
171
+ const xml = adb('shell', 'cat', '/sdcard/ui.xml')
172
+ legacySamples.push({ xml, elapsed_ms: elapsedMs(started) })
173
+ }
174
+ const bridgeNodes = nodes(bridgeSamples[0].xml)
175
+ const legacyNodes = nodes(legacySamples[0].xml)
176
+ const countIdentifiers = (items: Array<Record<string, string>>) => items.filter((node) => node['@_resource-id'] || node['@_content-desc']).length
177
+ const report = {
178
+ platform: 'android', device_id: '[redacted]', fixture: 'latency-journey-app',
179
+ network_freshness_passed: true,
180
+ silent_timer_freshness_passed: true,
181
+ bridge: { samples: summarize(bridgeSamples.map((sample) => sample.elapsed_ms)), node_count: bridgeNodes.length, identified_nodes: countIdentifiers(bridgeNodes) },
182
+ legacy: { samples: summarize(legacySamples.map((sample) => sample.elapsed_ms)), node_count: legacyNodes.length, identified_nodes: countIdentifiers(legacyNodes) }
183
+ }
184
+ const output = path.resolve('docs/benchmarks/ui-tree-performance/bridge-probe.json')
185
+ mkdirSync(path.dirname(output), { recursive: true })
186
+ writeFileSync(output, JSON.stringify(report, null, 2))
187
+ console.log(JSON.stringify(report))
188
+ } finally {
189
+ if (bridgeStarted) await stopBridge().catch(() => instrumentation.kill())
190
+ if (forwarded) { try { adb('forward', '--remove', `tcp:${hostPort}`) } catch { /* Best-effort cleanup. */ } }
191
+ if (!instrumentation.killed && instrumentation.exitCode === null) instrumentation.kill()
192
+ server.closeAllConnections()
193
+ await new Promise<void>((resolve) => server.close(() => resolve()))
194
+ }
195
+ }
196
+
197
+ main().catch((error) => { console.error(error); process.exitCode = 1 })
@@ -1,6 +1,7 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="dev.mobiledebugmcp.latencyfixture" android:versionCode="1" android:versionName="1.0">
2
+ <uses-permission android:name="android.permission.INTERNET"/>
2
3
  <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
+ <application android:theme="@android:style/Theme.Material.Light.NoActionBar" android:label="Latency fixture" android:allowBackup="false" android:usesCleartextTraffic="true">
4
5
  <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
6
  </application>
6
7
  </manifest>
@@ -7,4 +7,22 @@ tools; no Gradle dependency download is needed.
7
7
  Selectors are accessibility IDs: `saved-session`, `idle-detail`, `activate`,
8
8
  `active-detail`, `home`, `home-screen`, `deactivate`, `stop`, and `stopped`.
9
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.
10
+ Home with an inactive control. The static screen makes no network requests.
11
+
12
+ The performance harness at `test/device/automated/ui-tree-performance/benchmark.ts` can launch the deterministic network screen with
13
+ `networkBaseUrl` and `networkRunId` intent extras. That screen exposes `loading`,
14
+ `ready`, and `value` accessibility IDs and makes one request to the local harness
15
+ server; no public service is contacted.
16
+
17
+ Capture the instrumented baseline from a clean, built baseline checkout:
18
+
19
+ ```sh
20
+ UI_TREE_BASELINE_ROOT=/path/to/instrumented-baseline \
21
+ LATENCY_DEVICE_ID=emulator-5554 \
22
+ npx tsx test/device/automated/ui-tree-performance/benchmark.ts --baseline-only
23
+ ```
24
+
25
+ After implementing a candidate optimization, run the paired comparison from a
26
+ clean, built candidate checkout by also setting `UI_TREE_CANDIDATE_ROOT` and
27
+ omitting `--baseline-only`. Each run has three warm-ups and ten measured
28
+ attempts for the static journey and each controlled response delay.