mobile-debug-mcp 0.32.2 → 0.32.4

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.
@@ -1,35 +1,55 @@
1
1
  ---
2
2
  name: mobile-debug-mcp
3
- description: Verify Android app behaviour efficiently with mobile-debug MCP, especially session playback, background work, and notifications. Use for emulator/device QA; do not use for implementation-only checks.
3
+ description: Use mobile-debug MCP to inspect, interact with, and verify any Android or iOS app on a device or emulator.
4
4
  metadata:
5
- author: modul8-team
6
- version: "1.0"
5
+ author: mobile-debug-mcp contributors
6
+ version: "1.1"
7
7
  ---
8
8
 
9
- # Mobile-debug MCP verification
9
+ # Mobile-debug MCP usage
10
10
 
11
- Use mobile-debug MCP to make a product claim observable on a real device or emulator. A successful tap proves only that the input was delivered; verify the resulting UI, notification, or persisted state before reporting success.
11
+ Use mobile-debug MCP when a task needs evidence from a running Android or iOS app. Identify the device and the state or behaviour to check, then choose the smallest observation or action that can establish it. A successful tool call confirms only what that tool reports; check the resulting app state before claiming an outcome.
12
12
 
13
13
  ## Choose the lightest evidence that proves the claim
14
14
 
15
- - Use an accessibility assertion such as `expect_element_visible` or
16
- `wait_for_ui` for an accessible label, enabled state, or changed control.
17
- - Use a screenshot when visual layout, system UI, or notification content matters.
18
- - Use a debug snapshot only when the screen does not explain a failed action or an accessibility-tree assertion is needed.
19
- - Use Android shell commands only for device navigation or system surfaces that MCP cannot expose. Do not treat shell output as proof of user-visible behaviour.
15
+ - Prefer a screenshot for routine visual navigation, layout checks, and visible
16
+ outcomes that can be established from the image.
17
+ - Use UI-tree reads and accessibility assertions such as `expect_element_visible`,
18
+ `expect_state`, or `wait_for_ui` when reliable element selection or a structured
19
+ property check is required. An image alone does not establish accessibility
20
+ properties such as enabled state.
21
+ - Use a debug snapshot when an ordinary observation does not explain a failure or multiple kinds of evidence are needed together.
22
+ - Use logs or network activity for claims about errors or requests. Correlate them with the relevant action and time window; they do not establish what the user saw.
23
+ - Use platform shell commands only for device or system operations that the MCP tools cannot expose. Treat shell output as evidence of that operation, not of the app's visible state.
20
24
 
21
- Avoid repeated screenshots and snapshots when a targeted accessibility assertion is enough.
25
+ Choose one observation that proves the required outcome. Avoid automatically
26
+ reading the UI tree after a screenshot or taking a screenshot after a sufficient
27
+ structured assertion.
28
+
29
+ ## Keep Android inspection APK-free
30
+
31
+ Use the standard ADB path for Android inspection. It requires no helper APK.
32
+ The normal hierarchy dump
33
+ can take several seconds; account for this rather than treating it as a fast
34
+ poll. Compressed dumps can omit nodes and should not be assumed faster.
22
35
 
23
36
  ## Keep interaction fast
24
37
 
25
38
  Treat each MCP call as a round trip and use the fastest path that still proves
26
- the behaviour. Prefer the following sequence:
39
+ the behaviour. For an interaction, prefer this sequence:
27
40
 
28
- 1. Wait for one stable, uniquely identifiable control.
29
- 2. Tap its returned element id.
30
- 3. Wait for the specific resulting state.
41
+ 1. Observe the current screen or wait for a uniquely identifiable target if its presence is uncertain.
42
+ 2. Perform the requested action using a stable selector or element id when available.
43
+ 3. Check the specific resulting state once it has had time to occur.
31
44
 
32
- Do not wait before every tap when the current screen and element id are already known. Do not poll unchanged screens. Use short timeouts for ordinary controls; reserve longer waits for known asynchronous work such as model initialisation or network calls.
45
+ Do not wait before every action when its target and current state are already
46
+ known. Do not poll unchanged screens. Set bounded assertion timeouts that cover
47
+ the expected app transition plus a complete fresh observation and a margin.
48
+ For Android ADB tree reads taking 2–3 seconds, start with a 5-second timeout for
49
+ a simple assertion; allow more when loading or repeated observations are
50
+ required. A 2-second timeout cannot reliably accommodate a 3-second tree read.
51
+ Adjust to measured device latency and supported tool limits. Report an
52
+ observation timeout separately from evidence that a control is absent.
33
53
 
34
54
  Use compact responses for routine actions. If action dispatch is reliable but
35
55
  verification is slow or unavailable, dispatch with verification disabled and
@@ -37,11 +57,12 @@ verify at meaningful checkpoints with one focused accessibility assertion or
37
57
  screenshot. Do not assume `light` verification is faster: use it only when it
38
58
  has returned useful results reliably on the current MCP version and device.
39
59
 
40
- Prefer a stable element selector when the accessibility tree exposes one. Use
41
- coordinates only when needed, and take a fresh screenshot immediately before a
42
- coordinate tap on system UI, notifications, or any screen where layout may have
43
- changed. Never reuse coordinates after expanding/collapsing a notification,
44
- changing orientation, or switching screens.
60
+ Prefer a stable element selector when it is already known and valid. For a
61
+ clearly identifiable visual target, use coordinates from a fresh screenshot
62
+ without requiring a tree read solely to rediscover it. If the target is
63
+ ambiguous or a semantic check is required, resolve it from the hierarchy.
64
+ Reacquire coordinates after scrolling,
65
+ changing orientation, switching screens, or opening a system surface.
45
66
 
46
67
  Element discovery can be the slowest part of a journey. Avoid repeated
47
68
  `find_element` calls for the same screen: resolve a control once, use the
@@ -55,20 +76,21 @@ express the needed observable checks and it returns enough evidence to report
55
76
  the outcome. Measure the whole journey and compare equivalent steps on the same
56
77
  device; do not infer a performance improvement from a single fast action.
57
78
 
58
- ## Session playback checks
59
-
60
- For a claimed playback start, observe a running timer or Pause state after Play. For a claimed pause, observe Resume and unchanged elapsed time. For a claimed stop, observe that playback ends and the notification disappears.
79
+ ## Match the evidence to the claim
61
80
 
62
- For background playback, press Home, open the notification shade, and verify
63
- the notification title, progress/status, and available actions. Expand the
64
- session notification if necessary, then capture its current layout before
65
- tapping an action. Exercise each action needed for the change and observe its
66
- result: Pause becomes Resume; Resume returns to active playback; Stop ends
67
- playback and clears the notification. After each action, reacquire the current
68
- screen state before using another coordinate.
81
+ For a state change, check the state after the action rather than inferring it
82
+ from successful dispatch. For an asynchronous update, start a fresh observation
83
+ after the relevant event and wait for the expected condition within a bounded
84
+ timeout. If an accessibility tree appears stale or incomplete, use another
85
+ appropriate observation such as a screenshot and report the discrepancy.
69
86
 
70
- Do not claim spoken guidance was verified unless audio output was actually inspected. A running timer or active notification verifies playback state, not audible wording.
87
+ For behaviour outside the app's foreground screen, inspect the relevant system
88
+ surface or device state. For content that cannot be established visually, such
89
+ as audio output, use a suitable direct check or state that it was not verified.
71
90
 
72
91
  ## Report evidence precisely
73
92
 
74
- State the device journey, observed result, and any untested scope. Mention whether evidence came from an accessibility assertion, screenshot, or system notification. Do not say a feature works solely because the build compiled or an MCP action returned success.
93
+ State the device, actions taken, observed result, evidence source, and any
94
+ untested scope. Distinguish an observed outcome from an inference. Do not say
95
+ a feature works solely because the build compiled or an MCP action returned
96
+ success.
@@ -49,4 +49,4 @@ Activate when an agent needs to:
49
49
  - `references/spec-review-checklist.md` — questions to apply while reviewing a spec
50
50
 
51
51
  # License
52
- Same as repository (MIT).
52
+ Same as repository (Apache-2.0).
@@ -54,4 +54,4 @@ Activate when an agent needs to:
54
54
  - `references/test-quality-checklist.md` — what a good test in this repo should prove before it is considered done
55
55
 
56
56
  # License
57
- Same as repository (MIT).
57
+ Same as repository (Apache-2.0).
@@ -10,7 +10,6 @@ import { parsePngSize } from "../utils/image.js"
10
10
  import { deriveSnapshotMetadata } from "./snapshot-metadata.js"
11
11
  import { remainingBudget } from '../utils/operation-budget.js'
12
12
  import { createObservationMeasurement } from './performance-metrics.js'
13
- import { androidTreeBridgeEnabled, readAndroidTreeFromBridge } from './android-tree-bridge.js'
14
13
 
15
14
  const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
16
15
 
@@ -54,19 +53,7 @@ export class AndroidObserve {
54
53
  }
55
54
  remainingBudget()
56
55
 
57
- let bridgeContent: string | null = null
58
- if (androidTreeBridgeEnabled()) {
59
- try {
60
- bridgeContent = await measurement.physicalRead(async () => {
61
- const content = await readAndroidTreeFromBridge(deviceInfo.id)
62
- if (!content) throw new Error('Android UI tree bridge unavailable')
63
- return content
64
- })
65
- } catch (error) {
66
- if (error instanceof Error && (error.message === 'ACTION_TIMEOUT' || error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw error
67
- }
68
- }
69
- xmlContent = bridgeContent ?? await measurement.physicalRead(async () => {
56
+ xmlContent = await measurement.physicalRead(async () => {
70
57
  await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId)
71
58
  const content = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId)
72
59
  if (!content || !content.trim() || content.includes('ERROR:')) throw new Error('Empty or invalid UI dump')
@@ -74,7 +61,7 @@ export class AndroidObserve {
74
61
  })
75
62
  break
76
63
  } catch (e) {
77
- if (e instanceof Error && (e.message === 'ACTION_TIMEOUT' || e.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw e
64
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
78
65
  console.error(`Attempt ${attempts} failed: ${e}`);
79
66
  }
80
67
 
@@ -16,7 +16,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
16
16
 
17
17
  export const serverInfo = {
18
18
  name: 'mobile-debug-mcp',
19
- version: '0.32.2'
19
+ version: '0.32.4'
20
20
  }
21
21
 
22
22
  export function createServer() {
@@ -11,6 +11,7 @@ async function run() {
11
11
  const adbPath = path.join(directory, 'adb')
12
12
  const previousAdb = process.env.ADB_PATH
13
13
  const previousMetrics = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS
14
+ const previousBridgeApk = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK
14
15
  const previousFailure = process.env.MCP_FAKE_ADB_FAIL
15
16
  const previousWrite = process.stderr.write
16
17
  const lines: string[] = []
@@ -27,6 +28,7 @@ else process.exit(1)
27
28
  chmodSync(adbPath, 0o755)
28
29
  process.env.ADB_PATH = adbPath
29
30
  process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS = '1'
31
+ process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK = path.join(directory, 'missing-bridge.apk')
30
32
  ;(process.stderr as any).write = (chunk: string) => { lines.push(chunk); return true }
31
33
  resetSnapshotMetadataForTests()
32
34
  try {
@@ -60,6 +62,8 @@ else process.exit(1)
60
62
  else process.env.ADB_PATH = previousAdb
61
63
  if (previousMetrics === undefined) delete process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS
62
64
  else process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS = previousMetrics
65
+ if (previousBridgeApk === undefined) delete process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK
66
+ else process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK = previousBridgeApk
63
67
  if (previousFailure === undefined) delete process.env.MCP_FAKE_ADB_FAIL
64
68
  else process.env.MCP_FAKE_ADB_FAIL = previousFailure
65
69
  rmSync(directory, { recursive: true, force: true })
@@ -9,9 +9,9 @@ function stubExecCommand(expectedArgsChecker: (args: string[]) => boolean, outpu
9
9
  }
10
10
 
11
11
  async function run() {
12
- const bundle = 'com.ideamechanics.modul8'
12
+ const bundle = 'com.example.sampleapp'
13
13
  const pgrepOutput = '12345\n'
14
- const logOutput = '2026-03-31 09:21:20.085 Module[12345:678] <Info> Modul8: Test message'
14
+ const logOutput = '2026-03-31 09:21:20.085 SampleApp[12345:678] <Info> SampleApp: Test message'
15
15
 
16
16
  try {
17
17
  const obs = new iOSObserve()
@@ -28,7 +28,7 @@ async function run() {
28
28
  })
29
29
 
30
30
  const pidResult = await obs.getLogs({ appId: bundle, deviceId: 'booted' })
31
- assert(pidResult.meta.processNameUsed === 'modul8' || pidResult.meta.processNameUsed === 'Modul8' || !!pidResult.meta.processNameUsed)
31
+ assert(pidResult.meta.processNameUsed === 'sampleapp')
32
32
  assert(pidResult.meta.detectedPid === 12345)
33
33
  assert(pidResult.source === 'pid')
34
34
  assert(pidResult.logCount === 1)
@@ -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>