mobile-debug-mcp 0.32.0 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/observe/android-tree-bridge.js +192 -0
- package/dist/observe/android.js +49 -15
- package/dist/observe/ios.js +45 -29
- package/dist/observe/performance-metrics.js +124 -0
- package/dist/server/tool-handlers.js +31 -9
- package/dist/server-core.js +1 -1
- package/dist/utils/exec.js +15 -4
- package/docs/CHANGELOG.md +3 -0
- package/docs/ROADMAP.md +9 -0
- package/docs/benchmarks/ui-tree-performance/README.md +51 -0
- package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
- package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
- package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
- package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
- package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
- package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
- package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
- package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
- package/docs/tools/observe.md +1 -0
- package/package.json +1 -1
- package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
- package/src/observe/android-tree-bridge.ts +170 -0
- package/src/observe/android.ts +44 -17
- package/src/observe/ios.ts +45 -36
- package/src/observe/performance-metrics.ts +125 -0
- package/src/server/tool-handlers.ts +34 -10
- package/src/server-core.ts +1 -1
- package/src/utils/exec.ts +10 -4
- package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
- package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
- package/test/fixtures/latency-journey-app/AndroidManifest.xml +2 -1
- package/test/fixtures/latency-journey-app/README.md +19 -1
- package/test/fixtures/latency-journey-app/src/MainActivity.java +57 -1
- package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
- package/test/fixtures/ui-tree-bridge/build.ts +34 -0
- package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
- package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
- package/test/unit/observe/performance_metrics.test.ts +40 -0
- package/test/unit/server/operation_budget.test.ts +13 -0
- package/test/unit/server/run_journey.test.ts +30 -0
- /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
- /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
- /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
- /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
- /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
- /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
- /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
- /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
- /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
- /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
- /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
- /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
- /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
- /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
- /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
- /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,183 @@
|
|
|
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', 'pm', 'clear', fixturePackage)
|
|
115
|
+
adb('shell', 'am', 'start', '-n', `${fixturePackage}/.MainActivity`, '--es', 'networkBaseUrl', `http://10.0.2.2:${address.port}`, '--es', 'networkRunId', runId)
|
|
116
|
+
await Promise.race([requestPromise, sleep(10000).then(() => { throw new Error('Fixture request timed out') })])
|
|
117
|
+
const loadingDeadline = Date.now() + 10000
|
|
118
|
+
let loadingOnly = false
|
|
119
|
+
while (Date.now() < loadingDeadline) {
|
|
120
|
+
const tree = nodes((await bridgeRead()).xml)
|
|
121
|
+
loadingOnly = tree.some((node) => node['@_content-desc'] === 'loading') && !tree.some((node) => node['@_content-desc'] === 'ready' || node['@_content-desc'] === 'value')
|
|
122
|
+
if (loadingOnly) break
|
|
123
|
+
await sleep(50)
|
|
124
|
+
}
|
|
125
|
+
if (!loadingOnly) throw new Error('Bridge did not observe loading-only state')
|
|
126
|
+
releaseResponse()
|
|
127
|
+
while (!responseSentNs) await sleep(10)
|
|
128
|
+
const readyDeadline = Date.now() + 10000
|
|
129
|
+
let readyRead: Awaited<ReturnType<typeof bridgeRead>> | undefined
|
|
130
|
+
while (Date.now() < readyDeadline) {
|
|
131
|
+
const read = await bridgeRead()
|
|
132
|
+
const tree = nodes(read.xml)
|
|
133
|
+
if (tree.some((node) => node['@_content-desc'] === 'ready') && tree.some((node) => node['@_content-desc'] === 'value' && node['@_text'] === `value-${runId}`)) {
|
|
134
|
+
readyRead = read
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
await sleep(50)
|
|
138
|
+
}
|
|
139
|
+
if (!readyRead || readyRead.started_ns <= responseSentNs) throw new Error('Bridge ready/value assertion was not based on a post-response read')
|
|
140
|
+
const bridgeSamples = []
|
|
141
|
+
for (let index = 0; index < 10; index++) bridgeSamples.push(await bridgeRead())
|
|
142
|
+
await stopBridge()
|
|
143
|
+
bridgeStarted = false
|
|
144
|
+
if (instrumentation.exitCode === null) await new Promise<void>((resolve) => instrumentation.once('close', () => resolve()))
|
|
145
|
+
adb('forward', '--remove', `tcp:${hostPort}`)
|
|
146
|
+
forwarded = false
|
|
147
|
+
const legacySamples: Array<{ xml: string; elapsed_ms: number }> = []
|
|
148
|
+
for (let index = 0; index < 10; index++) {
|
|
149
|
+
const started = process.hrtime.bigint()
|
|
150
|
+
// Android may still be releasing the instrumentation accessibility connection.
|
|
151
|
+
for (let retry = 0; ; retry++) {
|
|
152
|
+
try { adb('shell', 'uiautomator', 'dump', '/sdcard/ui.xml'); break }
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (retry === 2) throw error
|
|
155
|
+
await sleep(500)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const xml = adb('shell', 'cat', '/sdcard/ui.xml')
|
|
159
|
+
legacySamples.push({ xml, elapsed_ms: elapsedMs(started) })
|
|
160
|
+
}
|
|
161
|
+
const bridgeNodes = nodes(bridgeSamples[0].xml)
|
|
162
|
+
const legacyNodes = nodes(legacySamples[0].xml)
|
|
163
|
+
const countIdentifiers = (items: Array<Record<string, string>>) => items.filter((node) => node['@_resource-id'] || node['@_content-desc']).length
|
|
164
|
+
const report = {
|
|
165
|
+
platform: 'android', device_id: '[redacted]', fixture: 'latency-journey-app',
|
|
166
|
+
network_freshness_passed: true,
|
|
167
|
+
bridge: { samples: summarize(bridgeSamples.map((sample) => sample.elapsed_ms)), node_count: bridgeNodes.length, identified_nodes: countIdentifiers(bridgeNodes) },
|
|
168
|
+
legacy: { samples: summarize(legacySamples.map((sample) => sample.elapsed_ms)), node_count: legacyNodes.length, identified_nodes: countIdentifiers(legacyNodes) }
|
|
169
|
+
}
|
|
170
|
+
const output = path.resolve('docs/benchmarks/ui-tree-performance/bridge-probe.json')
|
|
171
|
+
mkdirSync(path.dirname(output), { recursive: true })
|
|
172
|
+
writeFileSync(output, JSON.stringify(report, null, 2))
|
|
173
|
+
console.log(JSON.stringify(report))
|
|
174
|
+
} finally {
|
|
175
|
+
if (bridgeStarted) await stopBridge().catch(() => instrumentation.kill())
|
|
176
|
+
if (forwarded) { try { adb('forward', '--remove', `tcp:${hostPort}`) } catch { /* Best-effort cleanup. */ } }
|
|
177
|
+
if (!instrumentation.killed && instrumentation.exitCode === null) instrumentation.kill()
|
|
178
|
+
server.closeAllConnections()
|
|
179
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
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.
|
|
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.
|