mobile-debug-mcp 0.32.1 → 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.
- package/README.md +5 -1
- package/agent-plugin/LICENSE +201 -0
- package/agent-plugin/README.md +27 -0
- package/agent-plugin/mcp.json +11 -0
- package/agent-plugin/plugin.json +29 -0
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +96 -0
- package/dist/observe/android-tree-bridge.js +10 -0
- package/dist/observe/android.js +2 -18
- package/dist/server-core.js +1 -1
- package/docs/CHANGELOG.md +8 -2
- package/docs/benchmarks/ui-tree-performance/README.md +8 -0
- package/docs/benchmarks/ui-tree-performance/bridge-probe.json +9 -8
- package/docs/tools/observe.md +1 -1
- package/package.json +4 -2
- package/scripts/agent-plugin.mjs +38 -0
- package/skills/mcp-builder/SKILL.md +1 -2
- package/skills/mobile-debug-tools-usage/SKILL.md +55 -33
- package/skills/spec-review/SKILL.md +1 -1
- package/skills/test-authoring/SKILL.md +1 -1
- package/src/observe/android.ts +2 -15
- package/src/server-core.ts +1 -1
- package/test/fixtures/latency-journey-app/src/MainActivity.java +32 -1
- package/test/unit/observe/android_tree_acquisition.test.ts +4 -0
- package/test/unit/observe/ios-getlogs.test.ts +3 -3
- package/src/observe/android-tree-bridge.ts +0 -170
- package/test/device/automated/ui-tree-performance/bridge-probe.ts +0 -183
- package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +0 -6
- package/test/fixtures/ui-tree-bridge/build.ts +0 -34
- package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +0 -119
- package/test/unit/observe/android_tree_bridge_cleanup.test.ts +0 -76
|
@@ -1,35 +1,55 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mobile-debug-mcp
|
|
3
|
-
description:
|
|
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:
|
|
6
|
-
version: "1.
|
|
5
|
+
author: mobile-debug-mcp contributors
|
|
6
|
+
version: "1.1"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
# Mobile-debug MCP
|
|
9
|
+
# Mobile-debug MCP usage
|
|
10
10
|
|
|
11
|
-
Use mobile-debug MCP
|
|
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
|
-
-
|
|
16
|
-
|
|
17
|
-
- Use
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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.
|
|
39
|
+
the behaviour. For an interaction, prefer this sequence:
|
|
27
40
|
|
|
28
|
-
1.
|
|
29
|
-
2.
|
|
30
|
-
3.
|
|
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
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
##
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
package/src/observe/android.ts
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
64
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
78
65
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
79
66
|
}
|
|
80
67
|
|
package/src/server-core.ts
CHANGED
|
@@ -2,7 +2,12 @@ package dev.mobiledebugmcp.latencyfixture;
|
|
|
2
2
|
|
|
3
3
|
import android.app.Activity;
|
|
4
4
|
import android.content.Intent;
|
|
5
|
+
import android.graphics.Canvas;
|
|
6
|
+
import android.graphics.Color;
|
|
7
|
+
import android.graphics.Paint;
|
|
5
8
|
import android.os.Bundle;
|
|
9
|
+
import android.view.View;
|
|
10
|
+
import android.view.accessibility.AccessibilityNodeInfo;
|
|
6
11
|
import android.widget.Button;
|
|
7
12
|
import android.widget.LinearLayout;
|
|
8
13
|
import android.widget.TextView;
|
|
@@ -30,9 +35,35 @@ public final class MainActivity extends Activity {
|
|
|
30
35
|
super.onCreate(state);
|
|
31
36
|
String networkBaseUrl = getIntent().getStringExtra("networkBaseUrl");
|
|
32
37
|
String networkRunId = getIntent().getStringExtra("networkRunId");
|
|
33
|
-
if (
|
|
38
|
+
if (getIntent().getBooleanExtra("silentTimer", false)) silentTimerScreen();
|
|
39
|
+
else if (networkBaseUrl != null && networkRunId != null) networkScreen(networkBaseUrl, networkRunId);
|
|
34
40
|
else home();
|
|
35
41
|
}
|
|
42
|
+
private void silentTimerScreen() {
|
|
43
|
+
layout = new LinearLayout(this);
|
|
44
|
+
layout.setPadding(32, 100, 32, 32);
|
|
45
|
+
setContentView(layout);
|
|
46
|
+
SilentTimerView timer = new SilentTimerView();
|
|
47
|
+
layout.addView(timer, new LinearLayout.LayoutParams(-1, 200));
|
|
48
|
+
timer.postDelayed(() -> timer.setValue("0:01"), 2000);
|
|
49
|
+
}
|
|
50
|
+
private final class SilentTimerView extends View {
|
|
51
|
+
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
|
52
|
+
private String value = "0:00";
|
|
53
|
+
SilentTimerView() {
|
|
54
|
+
super(MainActivity.this);
|
|
55
|
+
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
|
|
56
|
+
paint.setColor(Color.BLACK);
|
|
57
|
+
paint.setTextSize(72);
|
|
58
|
+
}
|
|
59
|
+
// Deliberately redraw without an accessibility event to expose stale node caches.
|
|
60
|
+
void setValue(String next) { value = next; invalidate(); }
|
|
61
|
+
@Override protected void onDraw(Canvas canvas) { canvas.drawText(value, 20, 110, paint); }
|
|
62
|
+
@Override public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
|
|
63
|
+
super.onInitializeAccessibilityNodeInfo(info);
|
|
64
|
+
info.setContentDescription("silent timer " + value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
36
67
|
private void networkScreen(String baseUrl, String runId) {
|
|
37
68
|
layout = new LinearLayout(this);
|
|
38
69
|
layout.setOrientation(LinearLayout.VERTICAL);
|
|
@@ -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.
|
|
12
|
+
const bundle = 'com.example.sampleapp'
|
|
13
13
|
const pgrepOutput = '12345\n'
|
|
14
|
-
const logOutput = '2026-03-31 09:21:20.085
|
|
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 === '
|
|
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,170 +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
|
-
|
|
24
|
-
export function androidTreeBridgeEnabled(): boolean {
|
|
25
|
-
return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function configuredApk() {
|
|
29
|
-
const configured = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK
|
|
30
|
-
if (!configured) return null
|
|
31
|
-
const apk = path.resolve(configured)
|
|
32
|
-
if (!existsSync(apk)) throw new Error(`Android UI tree bridge APK does not exist: ${apk}`)
|
|
33
|
-
return apk
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function unbudgetedAdb(deviceId: string, args: string[]): Promise<void> {
|
|
37
|
-
return new Promise((resolve, reject) => {
|
|
38
|
-
execFile(getAdbCmd(), ['-s', deviceId, ...args], { timeout: 5000 }, (error) => error ? reject(error) : resolve())
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function request(bridge: Bridge, command: 'PING' | 'TREE' | 'STOP', timeoutMs: number): Promise<string> {
|
|
43
|
-
return new Promise((resolve, reject) => {
|
|
44
|
-
let output = ''
|
|
45
|
-
const socket = connect(bridge.hostPort, '127.0.0.1', () => socket.write(`${command}\n`))
|
|
46
|
-
socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`Android UI tree bridge ${command} timed out`)))
|
|
47
|
-
socket.on('data', (chunk) => {
|
|
48
|
-
output += chunk.toString()
|
|
49
|
-
if (command === 'STOP' && output.startsWith('OK\n')) {
|
|
50
|
-
resolve(output)
|
|
51
|
-
socket.destroy()
|
|
52
|
-
}
|
|
53
|
-
})
|
|
54
|
-
socket.on('error', reject)
|
|
55
|
-
socket.on('end', () => resolve(output))
|
|
56
|
-
})
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function waitForChildClose(bridge: Bridge, timeoutMs: number): Promise<void> {
|
|
60
|
-
if (bridge.childClosed) return Promise.resolve()
|
|
61
|
-
return new Promise((resolve, reject) => {
|
|
62
|
-
const onClose = () => { clearTimeout(timer); resolve() }
|
|
63
|
-
const timer = setTimeout(() => {
|
|
64
|
-
bridge.child.off('close', onClose)
|
|
65
|
-
reject(new Error('Android UI tree bridge child did not exit'))
|
|
66
|
-
}, timeoutMs)
|
|
67
|
-
bridge.child.once('close', onClose)
|
|
68
|
-
})
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function stop(bridge: Bridge): Promise<void> {
|
|
72
|
-
const pending = stopping.get(bridge.deviceId)
|
|
73
|
-
if (pending) return pending
|
|
74
|
-
bridge.closed = true
|
|
75
|
-
const cleanup = (async () => {
|
|
76
|
-
await request(bridge, 'STOP', 1000).catch(() => '')
|
|
77
|
-
let forceStopError: unknown
|
|
78
|
-
try { await unbudgetedAdb(bridge.deviceId, ['shell', 'am', 'force-stop', packageName]) }
|
|
79
|
-
catch (error) { forceStopError = error }
|
|
80
|
-
try { await waitForChildClose(bridge, 1000) }
|
|
81
|
-
catch {
|
|
82
|
-
bridge.child.kill('SIGTERM')
|
|
83
|
-
try { await waitForChildClose(bridge, 1000) }
|
|
84
|
-
catch {
|
|
85
|
-
bridge.child.kill('SIGKILL')
|
|
86
|
-
await waitForChildClose(bridge, 2000).catch(() => {})
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
let forwardError: unknown
|
|
90
|
-
try { await unbudgetedAdb(bridge.deviceId, ['forward', '--remove', `tcp:${bridge.hostPort}`]) }
|
|
91
|
-
catch (error) { forwardError = error }
|
|
92
|
-
if (forceStopError || !bridge.childClosed || forwardError) {
|
|
93
|
-
const reason = forceStopError ?? (!bridge.childClosed ? 'instrumentation child did not close' : forwardError)
|
|
94
|
-
throw new Error(`BRIDGE_CLEANUP_UNCONFIRMED: ${reason instanceof Error ? reason.message : String(reason)}`)
|
|
95
|
-
}
|
|
96
|
-
if (active.get(bridge.deviceId) === bridge) active.delete(bridge.deviceId)
|
|
97
|
-
})()
|
|
98
|
-
stopping.set(bridge.deviceId, cleanup)
|
|
99
|
-
try { await cleanup } finally { stopping.delete(bridge.deviceId) }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function launch(deviceId: string, apk: string): Promise<Bridge> {
|
|
103
|
-
await unbudgetedAdb(deviceId, ['shell', 'am', 'force-stop', packageName])
|
|
104
|
-
try {
|
|
105
|
-
await execAdb(['install', '-r', apk], deviceId)
|
|
106
|
-
} catch (error) {
|
|
107
|
-
if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE')) throw error
|
|
108
|
-
await execAdb(['uninstall', packageName], deviceId)
|
|
109
|
-
await execAdb(['install', '-r', apk], deviceId)
|
|
110
|
-
}
|
|
111
|
-
const hostPort = Number(await execAdb(['forward', 'tcp:0', `tcp:${devicePort}`], deviceId))
|
|
112
|
-
if (!Number.isInteger(hostPort) || hostPort <= 0) throw new Error('Android UI tree bridge could not obtain a host port')
|
|
113
|
-
const child = spawn(getAdbCmd(), ['-s', deviceId, 'shell', 'am', 'instrument', '-w', component], { stdio: 'ignore' })
|
|
114
|
-
const bridge: Bridge = { deviceId, hostPort, child, closed: false, childClosed: false }
|
|
115
|
-
child.once('close', () => { bridge.childClosed = true })
|
|
116
|
-
child.once('error', () => { /* Startup detects the failed child before a tree read. */ })
|
|
117
|
-
active.set(deviceId, bridge)
|
|
118
|
-
try {
|
|
119
|
-
const deadline = Date.now() + Math.min(remainingBudget(3000), 3000)
|
|
120
|
-
while (Date.now() < deadline) {
|
|
121
|
-
if (bridge.childClosed || child.exitCode !== null || child.signalCode !== null) throw new Error('Android UI tree bridge exited during startup')
|
|
122
|
-
try {
|
|
123
|
-
if ((await request(bridge, 'PING', 250)).startsWith('OK\n')) {
|
|
124
|
-
return bridge
|
|
125
|
-
}
|
|
126
|
-
} catch { /* The instrumentation process may still be starting. */ }
|
|
127
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
128
|
-
}
|
|
129
|
-
throw new Error('Android UI tree bridge did not become ready')
|
|
130
|
-
} catch (error) {
|
|
131
|
-
await stop(bridge)
|
|
132
|
-
throw error
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function ensure(deviceId: string, apk: string): Promise<Bridge> {
|
|
137
|
-
const pending = starting.get(deviceId)
|
|
138
|
-
if (pending) return pending
|
|
139
|
-
const ready = active.get(deviceId)
|
|
140
|
-
if (ready && !ready.closed && !ready.childClosed && ready.child.exitCode === null && ready.child.signalCode === null) return ready
|
|
141
|
-
if (ready) await stop(ready)
|
|
142
|
-
const promise = launch(deviceId, apk)
|
|
143
|
-
starting.set(deviceId, promise)
|
|
144
|
-
try { return await promise } finally { starting.delete(deviceId) }
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Returns null only after the bridge has been stopped, so legacy acquisition is safe. */
|
|
148
|
-
export async function readAndroidTreeFromBridge(deviceId: string): Promise<string | null> {
|
|
149
|
-
const apk = configuredApk()
|
|
150
|
-
if (!apk) return null
|
|
151
|
-
let bridge: Bridge | undefined
|
|
152
|
-
try {
|
|
153
|
-
bridge = await ensure(deviceId, apk)
|
|
154
|
-
const output = await request(bridge, 'TREE', remainingBudget(10000))
|
|
155
|
-
if (!output.startsWith('OK\n') || !output.includes('<hierarchy')) throw new Error('Android UI tree bridge returned invalid hierarchy output')
|
|
156
|
-
return output.slice(3)
|
|
157
|
-
} catch (error) {
|
|
158
|
-
if (error instanceof Error && error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')) throw error
|
|
159
|
-
if (bridge) await stop(bridge)
|
|
160
|
-
if (error instanceof Error && error.message === 'ACTION_TIMEOUT') throw error
|
|
161
|
-
return null
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
process.once('exit', () => {
|
|
166
|
-
for (const bridge of active.values()) {
|
|
167
|
-
spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'shell', 'am', 'force-stop', packageName], { timeout: 2000, stdio: 'ignore' })
|
|
168
|
-
spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'forward', '--remove', `tcp:${bridge.hostPort}`], { timeout: 2000, stdio: 'ignore' })
|
|
169
|
-
}
|
|
170
|
-
})
|
|
@@ -1,183 +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', '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 +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>
|