mobile-debug-mcp 0.32.2 → 0.33.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 (127) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/{skills → .agents/skills}/mcp-builder/SKILL.md +1 -2
  3. package/{skills → .agents/skills}/spec-review/SKILL.md +1 -1
  4. package/{skills → .agents/skills}/test-authoring/SKILL.md +1 -1
  5. package/.claude-plugin/marketplace.json +14 -0
  6. package/.cursor-plugin/marketplace.json +16 -0
  7. package/.github/copilot-instructions.md +7 -0
  8. package/AGENTS.md +21 -10
  9. package/README.md +67 -5
  10. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  11. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  12. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  13. package/agent-plugin/.mcp.json +14 -0
  14. package/agent-plugin/LICENSE +201 -0
  15. package/agent-plugin/README.md +29 -0
  16. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  17. package/agent-plugin/mcp.json +11 -0
  18. package/agent-plugin/plugin.json +29 -0
  19. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +124 -0
  20. package/dist/interact/android.js +10 -4
  21. package/dist/interact/conditional-action.js +68 -0
  22. package/dist/interact/index.js +54 -14
  23. package/dist/interact/ios.js +14 -8
  24. package/dist/manage/android.js +4 -3
  25. package/dist/manage/ios.js +3 -2
  26. package/dist/observe/android.js +27 -22
  27. package/dist/observe/evidence.js +273 -0
  28. package/dist/observe/index.js +33 -6
  29. package/dist/observe/ios.js +25 -6
  30. package/dist/observe/readiness-service.js +115 -0
  31. package/dist/observe/readiness.js +81 -0
  32. package/dist/observe/recording.js +165 -0
  33. package/dist/observe/snapshot-metadata.js +10 -2
  34. package/dist/server/common.js +9 -0
  35. package/dist/server/condition-schema.js +32 -0
  36. package/dist/server/journey-schema.js +3 -2
  37. package/dist/server/readiness-schema.js +12 -0
  38. package/dist/server/tool-definitions.js +46 -0
  39. package/dist/server/tool-handlers.js +99 -17
  40. package/dist/server-core.js +1 -1
  41. package/dist/utils/ios/utils.js +4 -4
  42. package/dist/utils/operation-budget.js +24 -2
  43. package/dist/utils/resolve-device.js +7 -0
  44. package/docs/CHANGELOG.md +16 -2
  45. package/docs/benchmarks/ui-tree-performance/README.md +4 -3
  46. package/docs/specs/020-conditional-actions/intent.md +62 -0
  47. package/docs/specs/020-conditional-actions/plan.md +214 -0
  48. package/docs/specs/020-conditional-actions/spec.md +301 -0
  49. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  50. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  51. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  52. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  53. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  54. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  55. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  56. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  57. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  58. package/docs/specs/process.md +213 -0
  59. package/docs/tools/TOOLS.md +3 -0
  60. package/docs/tools/evidence.md +79 -0
  61. package/docs/tools/interact.md +125 -2
  62. package/docs/tools/observe.md +64 -2
  63. package/package.json +4 -2
  64. package/scripts/agent-plugin.mjs +98 -0
  65. package/src/interact/android.ts +10 -4
  66. package/src/interact/conditional-action.ts +75 -0
  67. package/src/interact/index.ts +42 -14
  68. package/src/interact/ios.ts +14 -8
  69. package/src/manage/android.ts +4 -3
  70. package/src/manage/ios.ts +3 -2
  71. package/src/observe/android.ts +35 -21
  72. package/src/observe/evidence.ts +255 -0
  73. package/src/observe/index.ts +32 -8
  74. package/src/observe/ios.ts +32 -8
  75. package/src/observe/readiness-service.ts +89 -0
  76. package/src/observe/readiness.ts +78 -0
  77. package/src/observe/snapshot-metadata.ts +12 -1
  78. package/src/server/common.ts +7 -0
  79. package/src/server/condition-schema.ts +32 -0
  80. package/src/server/journey-schema.ts +3 -2
  81. package/src/server/readiness-schema.ts +12 -0
  82. package/src/server/tool-definitions.ts +44 -0
  83. package/src/server/tool-handlers.ts +93 -17
  84. package/src/server-core.ts +1 -1
  85. package/src/types.ts +30 -1
  86. package/src/utils/ios/utils.ts +4 -4
  87. package/src/utils/operation-budget.ts +22 -2
  88. package/src/utils/resolve-device.ts +8 -0
  89. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  90. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  91. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  92. package/test/device/manual/interact/readiness.manual.ts +99 -0
  93. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  94. package/test/fixtures/readiness-app/README.md +12 -0
  95. package/test/fixtures/readiness-app/build.ts +37 -0
  96. package/test/fixtures/readiness-app/main.m +27 -0
  97. package/test/unit/observe/android_tree_acquisition.test.ts +6 -0
  98. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  99. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  100. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  101. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  102. package/test/unit/observe/ios-getlogs.test.ts +3 -3
  103. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  104. package/test/unit/observe/readiness.test.ts +39 -0
  105. package/test/unit/observe/readiness_native.test.ts +68 -0
  106. package/test/unit/server/conditional_actions.test.ts +299 -0
  107. package/test/unit/server/conditional_schema.test.ts +59 -0
  108. package/test/unit/server/correlated-evidence.test.ts +194 -0
  109. package/test/unit/server/readiness.test.ts +170 -0
  110. package/test/unit/server/selector_tap_target.test.ts +79 -0
  111. package/test/unit/server/tap_selector.test.ts +2 -0
  112. package/skills/mobile-debug-tools-usage/SKILL.md +0 -74
  113. package/src/observe/android-tree-bridge.ts +0 -178
  114. package/test/device/automated/ui-tree-performance/bridge-probe.ts +0 -197
  115. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +0 -6
  116. package/test/fixtures/ui-tree-bridge/build.ts +0 -34
  117. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +0 -126
  118. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +0 -95
  119. /package/{skills → .agents/skills}/README.md +0 -0
  120. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  121. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  122. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  123. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  124. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  125. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  126. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  127. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -1,178 +0,0 @@
1
- import { execFile, spawn, spawnSync, type ChildProcess } from 'node:child_process'
2
- import { existsSync } from 'node:fs'
3
- import { connect } from 'node:net'
4
- import path from 'node:path'
5
- import { execAdb, getAdbCmd } from '../utils/android/utils.js'
6
- import { remainingBudget } from '../utils/operation-budget.js'
7
-
8
- const packageName = 'dev.mobiledebugmcp.treebridge'
9
- const component = `${packageName}/.TreeInstrumentation`
10
- const devicePort = 39019
11
-
12
- interface Bridge {
13
- deviceId: string
14
- hostPort: number
15
- child: ChildProcess
16
- closed: boolean
17
- childClosed: boolean
18
- }
19
-
20
- const active = new Map<string, Bridge>()
21
- const starting = new Map<string, Promise<Bridge>>()
22
- const stopping = new Map<string, Promise<void>>()
23
- const bridgeSupport = new Map<string, boolean>()
24
-
25
- export function androidTreeBridgeEnabled(): boolean {
26
- return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK)
27
- }
28
-
29
- function configuredApk() {
30
- const configured = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK
31
- if (!configured) return null
32
- const apk = path.resolve(configured)
33
- if (!existsSync(apk)) throw new Error(`Android UI tree bridge APK does not exist: ${apk}`)
34
- return apk
35
- }
36
-
37
- function unbudgetedAdb(deviceId: string, args: string[]): Promise<void> {
38
- return new Promise((resolve, reject) => {
39
- execFile(getAdbCmd(), ['-s', deviceId, ...args], { timeout: 5000 }, (error) => error ? reject(error) : resolve())
40
- })
41
- }
42
-
43
- function request(bridge: Bridge, command: 'PING' | 'TREE' | 'STOP', timeoutMs: number): Promise<string> {
44
- return new Promise((resolve, reject) => {
45
- let output = ''
46
- const socket = connect(bridge.hostPort, '127.0.0.1', () => socket.write(`${command}\n`))
47
- socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`Android UI tree bridge ${command} timed out`)))
48
- socket.on('data', (chunk) => {
49
- output += chunk.toString()
50
- if (command === 'STOP' && output.startsWith('OK\n')) {
51
- resolve(output)
52
- socket.destroy()
53
- }
54
- })
55
- socket.on('error', reject)
56
- socket.on('end', () => resolve(output))
57
- })
58
- }
59
-
60
- function waitForChildClose(bridge: Bridge, timeoutMs: number): Promise<void> {
61
- if (bridge.childClosed) return Promise.resolve()
62
- return new Promise((resolve, reject) => {
63
- const onClose = () => { clearTimeout(timer); resolve() }
64
- const timer = setTimeout(() => {
65
- bridge.child.off('close', onClose)
66
- reject(new Error('Android UI tree bridge child did not exit'))
67
- }, timeoutMs)
68
- bridge.child.once('close', onClose)
69
- })
70
- }
71
-
72
- async function stop(bridge: Bridge): Promise<void> {
73
- const pending = stopping.get(bridge.deviceId)
74
- if (pending) return pending
75
- bridge.closed = true
76
- const cleanup = (async () => {
77
- await request(bridge, 'STOP', 1000).catch(() => '')
78
- let forceStopError: unknown
79
- try { await unbudgetedAdb(bridge.deviceId, ['shell', 'am', 'force-stop', packageName]) }
80
- catch (error) { forceStopError = error }
81
- try { await waitForChildClose(bridge, 1000) }
82
- catch {
83
- bridge.child.kill('SIGTERM')
84
- try { await waitForChildClose(bridge, 1000) }
85
- catch {
86
- bridge.child.kill('SIGKILL')
87
- await waitForChildClose(bridge, 2000).catch(() => {})
88
- }
89
- }
90
- let forwardError: unknown
91
- try { await unbudgetedAdb(bridge.deviceId, ['forward', '--remove', `tcp:${bridge.hostPort}`]) }
92
- catch (error) { forwardError = error }
93
- if (forceStopError || !bridge.childClosed || forwardError) {
94
- const reason = forceStopError ?? (!bridge.childClosed ? 'instrumentation child did not close' : forwardError)
95
- throw new Error(`BRIDGE_CLEANUP_UNCONFIRMED: ${reason instanceof Error ? reason.message : String(reason)}`)
96
- }
97
- if (active.get(bridge.deviceId) === bridge) active.delete(bridge.deviceId)
98
- })()
99
- stopping.set(bridge.deviceId, cleanup)
100
- try { await cleanup } finally { stopping.delete(bridge.deviceId) }
101
- }
102
-
103
- async function launch(deviceId: string, apk: string): Promise<Bridge> {
104
- await unbudgetedAdb(deviceId, ['shell', 'am', 'force-stop', packageName])
105
- try {
106
- await execAdb(['install', '-r', apk], deviceId)
107
- } catch (error) {
108
- if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE')) throw error
109
- await execAdb(['uninstall', packageName], deviceId)
110
- await execAdb(['install', '-r', apk], deviceId)
111
- }
112
- const hostPort = Number(await execAdb(['forward', 'tcp:0', `tcp:${devicePort}`], deviceId))
113
- if (!Number.isInteger(hostPort) || hostPort <= 0) throw new Error('Android UI tree bridge could not obtain a host port')
114
- const child = spawn(getAdbCmd(), ['-s', deviceId, 'shell', 'am', 'instrument', '-w', component], { stdio: 'ignore' })
115
- const bridge: Bridge = { deviceId, hostPort, child, closed: false, childClosed: false }
116
- child.once('close', () => { bridge.childClosed = true })
117
- child.once('error', () => { /* Startup detects the failed child before a tree read. */ })
118
- active.set(deviceId, bridge)
119
- try {
120
- const deadline = Date.now() + Math.min(remainingBudget(3000), 3000)
121
- while (Date.now() < deadline) {
122
- if (bridge.childClosed || child.exitCode !== null || child.signalCode !== null) throw new Error('Android UI tree bridge exited during startup')
123
- try {
124
- if ((await request(bridge, 'PING', 250)).startsWith('OK\n')) {
125
- return bridge
126
- }
127
- } catch { /* The instrumentation process may still be starting. */ }
128
- await new Promise((resolve) => setTimeout(resolve, 100))
129
- }
130
- throw new Error('Android UI tree bridge did not become ready')
131
- } catch (error) {
132
- await stop(bridge)
133
- throw error
134
- }
135
- }
136
-
137
- async function ensure(deviceId: string, apk: string): Promise<Bridge> {
138
- const pending = starting.get(deviceId)
139
- if (pending) return pending
140
- const ready = active.get(deviceId)
141
- if (ready && !ready.closed && !ready.childClosed && ready.child.exitCode === null && ready.child.signalCode === null) return ready
142
- if (ready) await stop(ready)
143
- const promise = launch(deviceId, apk)
144
- starting.set(deviceId, promise)
145
- try { return await promise } finally { starting.delete(deviceId) }
146
- }
147
-
148
- /** Returns null only after the bridge has been stopped, so legacy acquisition is safe. */
149
- export async function readAndroidTreeFromBridge(deviceId: string): Promise<string | null> {
150
- const apk = configuredApk()
151
- if (!apk) return null
152
- let bridge: Bridge | undefined
153
- try {
154
- let supported = bridgeSupport.get(deviceId)
155
- if (supported === undefined) {
156
- const sdkLevel = Number(await execAdb(['shell', 'getprop', 'ro.build.version.sdk'], deviceId))
157
- supported = Number.isInteger(sdkLevel) && sdkLevel >= 34
158
- if (supported) bridgeSupport.set(deviceId, true)
159
- }
160
- if (!supported) return null
161
- bridge = await ensure(deviceId, apk)
162
- const output = await request(bridge, 'TREE', remainingBudget(10000))
163
- if (!output.startsWith('OK\n') || !output.includes('<hierarchy')) throw new Error('Android UI tree bridge returned invalid hierarchy output')
164
- return output.slice(3)
165
- } catch (error) {
166
- if (error instanceof Error && error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')) throw error
167
- if (bridge) await stop(bridge)
168
- if (error instanceof Error && error.message === 'ACTION_TIMEOUT') throw error
169
- return null
170
- }
171
- }
172
-
173
- process.once('exit', () => {
174
- for (const bridge of active.values()) {
175
- spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'shell', 'am', 'force-stop', packageName], { timeout: 2000, stdio: 'ignore' })
176
- spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'forward', '--remove', `tcp:${bridge.hostPort}`], { timeout: 2000, stdio: 'ignore' })
177
- }
178
- })
@@ -1,197 +0,0 @@
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 +0,0 @@
1
- <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="dev.mobiledebugmcp.treebridge">
2
- <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36" />
3
- <uses-permission android:name="android.permission.INTERNET" />
4
- <application android:label="UI Tree Bridge" />
5
- <instrumentation android:name=".TreeInstrumentation" android:targetPackage="dev.mobiledebugmcp.treebridge" android:exported="true" />
6
- </manifest>
@@ -1,34 +0,0 @@
1
- import { execFileSync } from 'node:child_process'
2
- import { existsSync, mkdirSync } from 'node:fs'
3
- import path from 'node:path'
4
- import { fileURLToPath } from 'node:url'
5
-
6
- export function buildBridge() {
7
- const root = path.dirname(fileURLToPath(import.meta.url))
8
- const out = path.join(root, 'build')
9
- const sdk = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME ||
10
- (process.platform === 'darwin' && process.env.HOME ? path.join(process.env.HOME, 'Library/Android/sdk') : '')
11
- if (!sdk) throw new Error('Android SDK not found: set ANDROID_SDK_ROOT or ANDROID_HOME')
12
- const buildTools = path.join(sdk, 'build-tools', '36.0.0')
13
- const androidJar = path.join(sdk, 'platforms', 'android-36', 'android.jar')
14
- if (!existsSync(androidJar) || !existsSync(buildTools)) {
15
- throw new Error(`Android SDK 36 platform and build-tools 36.0.0 are required under ${sdk}`)
16
- }
17
- const java = process.env.JAVA_HOME
18
- const tool = (name: string) => java ? path.join(java, 'bin', name) : name
19
- const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { cwd: out, stdio: 'pipe' })
20
- mkdirSync(path.join(out, 'classes'), { recursive: true })
21
- run(tool('javac'), ['--release', '8', '-classpath', androidJar, '-d', 'classes', path.join(root, 'src/TreeInstrumentation.java')])
22
- run(tool('jar'), ['cf', 'classes.jar', '-C', 'classes', '.'])
23
- run(path.join(buildTools, 'd8'), ['--lib', androidJar, '--output', out, 'classes.jar'])
24
- run(path.join(buildTools, 'aapt2'), ['link', '-I', androidJar, '--manifest', path.join(root, 'AndroidManifest.xml'), '-o', 'unsigned.apk'])
25
- run('zip', ['-q', 'unsigned.apk', 'classes.dex'])
26
- run(path.join(buildTools, 'zipalign'), ['-f', '4', 'unsigned.apk', 'aligned.apk'])
27
- try { run(tool('keytool'), ['-genkeypair', '-keystore', 'fixture.jks', '-storepass', 'android', '-keypass', 'android', '-alias', 'fixture', '-dname', 'CN=UI Tree Bridge', '-keyalg', 'RSA', '-validity', '3650']) } catch { /* Existing local fixture key. */ }
28
- run(path.join(buildTools, 'apksigner'), ['sign', '--ks', 'fixture.jks', '--ks-pass', 'pass:android', '--out', 'bridge.apk', 'aligned.apk'])
29
- return path.join(out, 'bridge.apk')
30
- }
31
-
32
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
33
- console.log(buildBridge())
34
- }
@@ -1,126 +0,0 @@
1
- package dev.mobiledebugmcp.treebridge;
2
-
3
- import android.app.Instrumentation;
4
- import android.app.UiAutomation;
5
- import android.graphics.Rect;
6
- import android.os.Bundle;
7
- import android.os.Build;
8
- import android.util.Xml;
9
- import android.util.Log;
10
- import android.view.accessibility.AccessibilityNodeInfo;
11
- import java.io.BufferedReader;
12
- import java.io.InputStreamReader;
13
- import java.io.OutputStreamWriter;
14
- import java.io.StringWriter;
15
- import java.net.InetAddress;
16
- import java.net.ServerSocket;
17
- import java.net.Socket;
18
- import org.xmlpull.v1.XmlSerializer;
19
-
20
- /** Benchmark-only bridge: one live accessibility acquisition per TREE request. */
21
- public final class TreeInstrumentation extends Instrumentation {
22
- private static final int PORT = 39019;
23
-
24
- @Override public void onCreate(Bundle arguments) {
25
- super.onCreate(arguments);
26
- start();
27
- }
28
-
29
- @Override public void onStart() {
30
- try (ServerSocket server = new ServerSocket(PORT, 1, InetAddress.getByName("127.0.0.1"))) {
31
- UiAutomation automation = getUiAutomation(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES);
32
- while (true) {
33
- try (Socket socket = server.accept()) {
34
- socket.setSoTimeout(10000);
35
- BufferedReader input = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
36
- OutputStreamWriter output = new OutputStreamWriter(socket.getOutputStream(), "UTF-8");
37
- String command = input.readLine();
38
- if ("STOP".equals(command)) {
39
- output.write("OK\n");
40
- output.flush();
41
- break;
42
- }
43
- if ("PING".equals(command)) {
44
- output.write("OK\n");
45
- output.flush();
46
- continue;
47
- }
48
- if (!"TREE".equals(command)) {
49
- output.write("ERROR invalid_command\n");
50
- } else {
51
- if (Build.VERSION.SDK_INT < 34) {
52
- output.write("ERROR cache_clear_unavailable\n");
53
- } else if (!automation.clearCache()) {
54
- output.write("ERROR cache_clear_failed\n");
55
- } else {
56
- AccessibilityNodeInfo root = automation.getRootInActiveWindow();
57
- if (root == null) {
58
- output.write("ERROR null_root\n");
59
- } else {
60
- output.write("OK\n");
61
- output.write(serialize(root));
62
- root.recycle();
63
- }
64
- }
65
- }
66
- output.flush();
67
- } catch (Exception error) {
68
- // A failed client request cannot make a later acquisition reuse its tree.
69
- Log.e("TreeBridge", "request failed", error);
70
- }
71
- }
72
- } catch (Exception error) {
73
- // The host-side probe detects failure to start or connect.
74
- Log.e("TreeBridge", "bridge failed", error);
75
- }
76
- finish(0, new Bundle());
77
- }
78
-
79
- private static String serialize(AccessibilityNodeInfo root) throws Exception {
80
- StringWriter writer = new StringWriter();
81
- XmlSerializer xml = Xml.newSerializer();
82
- xml.setOutput(writer);
83
- xml.startDocument("UTF-8", true);
84
- xml.startTag("", "hierarchy");
85
- writeNode(xml, root, 0);
86
- xml.endTag("", "hierarchy");
87
- xml.endDocument();
88
- return writer.toString();
89
- }
90
-
91
- private static void writeNode(XmlSerializer xml, AccessibilityNodeInfo node, int index) throws Exception {
92
- Rect bounds = new Rect();
93
- node.getBoundsInScreen(bounds);
94
- xml.startTag("", "node");
95
- xml.attribute("", "index", Integer.toString(index));
96
- xml.attribute("", "class", value(node.getClassName()));
97
- xml.attribute("", "package", value(node.getPackageName()));
98
- xml.attribute("", "text", value(node.getText()));
99
- xml.attribute("", "content-desc", value(node.getContentDescription()));
100
- xml.attribute("", "resource-id", value(node.getViewIdResourceName()));
101
- xml.attribute("", "checkable", Boolean.toString(node.isCheckable()));
102
- xml.attribute("", "checked", Boolean.toString(node.isChecked()));
103
- xml.attribute("", "clickable", Boolean.toString(node.isClickable()));
104
- xml.attribute("", "enabled", Boolean.toString(node.isEnabled()));
105
- xml.attribute("", "focusable", Boolean.toString(node.isFocusable()));
106
- xml.attribute("", "focused", Boolean.toString(node.isFocused()));
107
- xml.attribute("", "scrollable", Boolean.toString(node.isScrollable()));
108
- xml.attribute("", "long-clickable", Boolean.toString(node.isLongClickable()));
109
- xml.attribute("", "password", Boolean.toString(node.isPassword()));
110
- xml.attribute("", "selected", Boolean.toString(node.isSelected()));
111
- xml.attribute("", "visible-to-user", Boolean.toString(node.isVisibleToUser()));
112
- xml.attribute("", "bounds", bounds.toShortString());
113
- for (int childIndex = 0; childIndex < node.getChildCount(); childIndex++) {
114
- AccessibilityNodeInfo child = node.getChild(childIndex);
115
- if (child != null && child.isVisibleToUser()) {
116
- writeNode(xml, child, childIndex);
117
- }
118
- if (child != null) child.recycle();
119
- }
120
- xml.endTag("", "node");
121
- }
122
-
123
- private static String value(CharSequence input) {
124
- return input == null ? "" : input.toString();
125
- }
126
- }
@@ -1,95 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
- import { createServer } from 'node:net'
4
- import type { Socket } from 'node:net'
5
- import { tmpdir } from 'node:os'
6
- import path from 'node:path'
7
- import { readAndroidTreeFromBridge } from '../../../src/observe/android-tree-bridge.js'
8
-
9
- async function run() {
10
- const directory = mkdtempSync(path.join(tmpdir(), 'mcp-tree-bridge-'))
11
- const adbPath = path.join(directory, 'adb')
12
- const apkPath = path.join(directory, 'bridge.apk')
13
- const launchesPath = path.join(directory, 'launches')
14
- const getpropsPath = path.join(directory, 'getprops')
15
- const previous = new Map(['ADB_PATH', 'MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK', 'MCP_FAKE_BRIDGE_PORT', 'MCP_FAKE_BRIDGE_LAUNCHES', 'MCP_FAKE_BRIDGE_GETPROPS', 'MCP_FAKE_FORCE_STOP_FAIL', 'MCP_FAKE_SDK'].map((key) => [key, process.env[key]]))
16
- let invalidTree = false
17
- const sockets = new Set<Socket>()
18
- const server = createServer((socket) => {
19
- sockets.add(socket)
20
- socket.once('close', () => sockets.delete(socket))
21
- socket.once('data', (chunk) => {
22
- const command = chunk.toString().trim()
23
- socket.end(command === 'TREE'
24
- ? invalidTree ? 'ERROR null_root\n' : 'OK\n<hierarchy><node class="android.view.View" /></hierarchy>'
25
- : 'OK\n')
26
- })
27
- })
28
- writeFileSync(apkPath, '')
29
- writeFileSync(launchesPath, '')
30
- writeFileSync(getpropsPath, '')
31
- writeFileSync(adbPath, `#!/usr/bin/env node
32
- const fs = require('node:fs')
33
- const args = process.argv.slice(2).join(' ')
34
- if (args.includes('am instrument')) {
35
- fs.appendFileSync(process.env.MCP_FAKE_BRIDGE_LAUNCHES, 'launch\\n')
36
- setInterval(() => {}, 1000)
37
- } else if (args.includes('am force-stop') && process.env.MCP_FAKE_FORCE_STOP_FAIL === '1') {
38
- process.exit(1)
39
- } else if (args.includes('forward tcp:0')) {
40
- console.log(process.env.MCP_FAKE_BRIDGE_PORT)
41
- } else if (args.includes('getprop ro.build.version.sdk')) {
42
- fs.appendFileSync(process.env.MCP_FAKE_BRIDGE_GETPROPS, 'getprop\\n')
43
- console.log(process.env.MCP_FAKE_SDK || '36')
44
- }
45
- `)
46
- chmodSync(adbPath, 0o755)
47
- await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
48
- const address = server.address()
49
- if (!address || typeof address === 'string') throw new Error('No test port')
50
- process.env.ADB_PATH = adbPath
51
- process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK = apkPath
52
- process.env.MCP_FAKE_BRIDGE_PORT = String(address.port)
53
- process.env.MCP_FAKE_BRIDGE_LAUNCHES = launchesPath
54
- process.env.MCP_FAKE_BRIDGE_GETPROPS = getpropsPath
55
- try {
56
- assert.match(await readAndroidTreeFromBridge('test-device') ?? '', /<hierarchy>/)
57
- invalidTree = true
58
- process.env.MCP_FAKE_FORCE_STOP_FAIL = '1'
59
- await assert.rejects(() => readAndroidTreeFromBridge('test-device'), /BRIDGE_CLEANUP_UNCONFIRMED/)
60
- await assert.rejects(() => readAndroidTreeFromBridge('test-device'), /BRIDGE_CLEANUP_UNCONFIRMED/)
61
- assert.equal(readFileSync(launchesPath, 'utf8').trim().split('\n').length, 1, 'failed cleanup must block a second launch')
62
- invalidTree = false
63
- delete process.env.MCP_FAKE_FORCE_STOP_FAIL
64
- assert.match(await readAndroidTreeFromBridge('test-device') ?? '', /<hierarchy>/, 'successful cleanup allows relaunch')
65
- for (let attempt = 0; attempt < 20 && readFileSync(launchesPath, 'utf8').trim().split('\n').length < 2; attempt++) {
66
- await new Promise((resolve) => setTimeout(resolve, 50))
67
- }
68
- assert.equal(readFileSync(launchesPath, 'utf8').trim().split('\n').length, 2)
69
- invalidTree = true
70
- assert.equal(await readAndroidTreeFromBridge('test-device'), null, 'clean shutdown permits legacy fallback')
71
- process.env.MCP_FAKE_SDK = '33'
72
- assert.equal(await readAndroidTreeFromBridge('old-device'), null, 'older Android versions use the legacy reader')
73
- assert.equal(await readAndroidTreeFromBridge('old-device'), null)
74
- assert.equal(readFileSync(getpropsPath, 'utf8').trim().split('\n').length, 3, 'unsupported devices recheck SDK support')
75
- process.env.MCP_FAKE_SDK = '36'
76
- invalidTree = false
77
- assert.match(await readAndroidTreeFromBridge('old-device') ?? '', /<hierarchy>/, 'a newly supported device can start the bridge')
78
- for (let attempt = 0; attempt < 20 && readFileSync(launchesPath, 'utf8').trim().split('\n').length < 3; attempt++) {
79
- await new Promise((resolve) => setTimeout(resolve, 50))
80
- }
81
- assert.equal(readFileSync(launchesPath, 'utf8').trim().split('\n').length, 3)
82
- invalidTree = true
83
- assert.equal(await readAndroidTreeFromBridge('old-device'), null, 'the new bridge shuts down before fallback')
84
- } finally {
85
- for (const [key, value] of previous) {
86
- if (value === undefined) delete process.env[key]
87
- else process.env[key] = value
88
- }
89
- for (const socket of sockets) socket.destroy()
90
- await new Promise<void>((resolve) => server.close(() => resolve()))
91
- rmSync(directory, { recursive: true, force: true })
92
- }
93
- }
94
-
95
- run().catch((error) => { console.error(error); process.exitCode = 1 })
File without changes