mobile-debug-mcp 0.32.0 → 0.32.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/observe/android-tree-bridge.js +202 -0
- package/dist/observe/android.js +49 -15
- package/dist/observe/ios.js +45 -29
- package/dist/observe/performance-metrics.js +124 -0
- package/dist/server/tool-handlers.js +31 -9
- package/dist/server-core.js +1 -1
- package/dist/utils/exec.js +15 -4
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +9 -0
- package/docs/benchmarks/ui-tree-performance/README.md +58 -0
- package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
- package/docs/benchmarks/ui-tree-performance/bridge-probe.json +29 -0
- package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
- package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
- package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
- package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
- package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
- package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
- package/docs/tools/observe.md +2 -0
- package/package.json +1 -1
- package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
- package/src/observe/android-tree-bridge.ts +178 -0
- package/src/observe/android.ts +44 -17
- package/src/observe/ios.ts +45 -36
- package/src/observe/performance-metrics.ts +125 -0
- package/src/server/tool-handlers.ts +34 -10
- package/src/server-core.ts +1 -1
- package/src/utils/exec.ts +10 -4
- package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
- package/test/device/automated/ui-tree-performance/bridge-probe.ts +197 -0
- package/test/fixtures/latency-journey-app/AndroidManifest.xml +2 -1
- package/test/fixtures/latency-journey-app/README.md +19 -1
- package/test/fixtures/latency-journey-app/src/MainActivity.java +88 -1
- package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
- package/test/fixtures/ui-tree-bridge/build.ts +34 -0
- package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +126 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
- package/test/unit/observe/android_tree_bridge_cleanup.test.ts +95 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
- package/test/unit/observe/performance_metrics.test.ts +40 -0
- package/test/unit/server/operation_budget.test.ts +13 -0
- package/test/unit/server/run_journey.test.ts +30 -0
- /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
- /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
- /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
- /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
- /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
- /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
- /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
- /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
- /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
- /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
- /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
- /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
- /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
- /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
- /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
- /package/docs/{specs-deltas/018-interaction-latency-and-batched-journeys.md → specs/018-interaction-latency-and-batched-journeys/spec.md} +0 -0
package/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# Mobile Debug Tools
|
|
2
|
+
[](https://www.npmjs.com/package/mobile-debug-mcp)
|
|
2
3
|
|
|
3
4
|
A minimal, secure MCP server for AI-assisted mobile development. Build, install, interact and inspect Android/iOS apps from an MCP-compatible client.
|
|
4
5
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { execFile, spawn, spawnSync } 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
|
+
const packageName = 'dev.mobiledebugmcp.treebridge';
|
|
8
|
+
const component = `${packageName}/.TreeInstrumentation`;
|
|
9
|
+
const devicePort = 39019;
|
|
10
|
+
const active = new Map();
|
|
11
|
+
const starting = new Map();
|
|
12
|
+
const stopping = new Map();
|
|
13
|
+
const bridgeSupport = new Map();
|
|
14
|
+
export function androidTreeBridgeEnabled() {
|
|
15
|
+
return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK);
|
|
16
|
+
}
|
|
17
|
+
function configuredApk() {
|
|
18
|
+
const configured = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK;
|
|
19
|
+
if (!configured)
|
|
20
|
+
return null;
|
|
21
|
+
const apk = path.resolve(configured);
|
|
22
|
+
if (!existsSync(apk))
|
|
23
|
+
throw new Error(`Android UI tree bridge APK does not exist: ${apk}`);
|
|
24
|
+
return apk;
|
|
25
|
+
}
|
|
26
|
+
function unbudgetedAdb(deviceId, args) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
execFile(getAdbCmd(), ['-s', deviceId, ...args], { timeout: 5000 }, (error) => error ? reject(error) : resolve());
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function request(bridge, command, timeoutMs) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
let output = '';
|
|
34
|
+
const socket = connect(bridge.hostPort, '127.0.0.1', () => socket.write(`${command}\n`));
|
|
35
|
+
socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`Android UI tree bridge ${command} timed out`)));
|
|
36
|
+
socket.on('data', (chunk) => {
|
|
37
|
+
output += chunk.toString();
|
|
38
|
+
if (command === 'STOP' && output.startsWith('OK\n')) {
|
|
39
|
+
resolve(output);
|
|
40
|
+
socket.destroy();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
socket.on('error', reject);
|
|
44
|
+
socket.on('end', () => resolve(output));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function waitForChildClose(bridge, timeoutMs) {
|
|
48
|
+
if (bridge.childClosed)
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const onClose = () => { clearTimeout(timer); resolve(); };
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
bridge.child.off('close', onClose);
|
|
54
|
+
reject(new Error('Android UI tree bridge child did not exit'));
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
bridge.child.once('close', onClose);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function stop(bridge) {
|
|
60
|
+
const pending = stopping.get(bridge.deviceId);
|
|
61
|
+
if (pending)
|
|
62
|
+
return pending;
|
|
63
|
+
bridge.closed = true;
|
|
64
|
+
const cleanup = (async () => {
|
|
65
|
+
await request(bridge, 'STOP', 1000).catch(() => '');
|
|
66
|
+
let forceStopError;
|
|
67
|
+
try {
|
|
68
|
+
await unbudgetedAdb(bridge.deviceId, ['shell', 'am', 'force-stop', packageName]);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
forceStopError = error;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await waitForChildClose(bridge, 1000);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
bridge.child.kill('SIGTERM');
|
|
78
|
+
try {
|
|
79
|
+
await waitForChildClose(bridge, 1000);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
bridge.child.kill('SIGKILL');
|
|
83
|
+
await waitForChildClose(bridge, 2000).catch(() => { });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let forwardError;
|
|
87
|
+
try {
|
|
88
|
+
await unbudgetedAdb(bridge.deviceId, ['forward', '--remove', `tcp:${bridge.hostPort}`]);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
forwardError = error;
|
|
92
|
+
}
|
|
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)
|
|
98
|
+
active.delete(bridge.deviceId);
|
|
99
|
+
})();
|
|
100
|
+
stopping.set(bridge.deviceId, cleanup);
|
|
101
|
+
try {
|
|
102
|
+
await cleanup;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
stopping.delete(bridge.deviceId);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function launch(deviceId, apk) {
|
|
109
|
+
await unbudgetedAdb(deviceId, ['shell', 'am', 'force-stop', packageName]);
|
|
110
|
+
try {
|
|
111
|
+
await execAdb(['install', '-r', apk], deviceId);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE'))
|
|
115
|
+
throw error;
|
|
116
|
+
await execAdb(['uninstall', packageName], deviceId);
|
|
117
|
+
await execAdb(['install', '-r', apk], deviceId);
|
|
118
|
+
}
|
|
119
|
+
const hostPort = Number(await execAdb(['forward', 'tcp:0', `tcp:${devicePort}`], deviceId));
|
|
120
|
+
if (!Number.isInteger(hostPort) || hostPort <= 0)
|
|
121
|
+
throw new Error('Android UI tree bridge could not obtain a host port');
|
|
122
|
+
const child = spawn(getAdbCmd(), ['-s', deviceId, 'shell', 'am', 'instrument', '-w', component], { stdio: 'ignore' });
|
|
123
|
+
const bridge = { deviceId, hostPort, child, closed: false, childClosed: false };
|
|
124
|
+
child.once('close', () => { bridge.childClosed = true; });
|
|
125
|
+
child.once('error', () => { });
|
|
126
|
+
active.set(deviceId, bridge);
|
|
127
|
+
try {
|
|
128
|
+
const deadline = Date.now() + Math.min(remainingBudget(3000), 3000);
|
|
129
|
+
while (Date.now() < deadline) {
|
|
130
|
+
if (bridge.childClosed || child.exitCode !== null || child.signalCode !== null)
|
|
131
|
+
throw new Error('Android UI tree bridge exited during startup');
|
|
132
|
+
try {
|
|
133
|
+
if ((await request(bridge, 'PING', 250)).startsWith('OK\n')) {
|
|
134
|
+
return bridge;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch { /* The instrumentation process may still be starting. */ }
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
139
|
+
}
|
|
140
|
+
throw new Error('Android UI tree bridge did not become ready');
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
await stop(bridge);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function ensure(deviceId, apk) {
|
|
148
|
+
const pending = starting.get(deviceId);
|
|
149
|
+
if (pending)
|
|
150
|
+
return pending;
|
|
151
|
+
const ready = active.get(deviceId);
|
|
152
|
+
if (ready && !ready.closed && !ready.childClosed && ready.child.exitCode === null && ready.child.signalCode === null)
|
|
153
|
+
return ready;
|
|
154
|
+
if (ready)
|
|
155
|
+
await stop(ready);
|
|
156
|
+
const promise = launch(deviceId, apk);
|
|
157
|
+
starting.set(deviceId, promise);
|
|
158
|
+
try {
|
|
159
|
+
return await promise;
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
starting.delete(deviceId);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Returns null only after the bridge has been stopped, so legacy acquisition is safe. */
|
|
166
|
+
export async function readAndroidTreeFromBridge(deviceId) {
|
|
167
|
+
const apk = configuredApk();
|
|
168
|
+
if (!apk)
|
|
169
|
+
return null;
|
|
170
|
+
let bridge;
|
|
171
|
+
try {
|
|
172
|
+
let supported = bridgeSupport.get(deviceId);
|
|
173
|
+
if (supported === undefined) {
|
|
174
|
+
const sdkLevel = Number(await execAdb(['shell', 'getprop', 'ro.build.version.sdk'], deviceId));
|
|
175
|
+
supported = Number.isInteger(sdkLevel) && sdkLevel >= 34;
|
|
176
|
+
if (supported)
|
|
177
|
+
bridgeSupport.set(deviceId, true);
|
|
178
|
+
}
|
|
179
|
+
if (!supported)
|
|
180
|
+
return null;
|
|
181
|
+
bridge = await ensure(deviceId, apk);
|
|
182
|
+
const output = await request(bridge, 'TREE', remainingBudget(10000));
|
|
183
|
+
if (!output.startsWith('OK\n') || !output.includes('<hierarchy'))
|
|
184
|
+
throw new Error('Android UI tree bridge returned invalid hierarchy output');
|
|
185
|
+
return output.slice(3);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error instanceof Error && error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))
|
|
189
|
+
throw error;
|
|
190
|
+
if (bridge)
|
|
191
|
+
await stop(bridge);
|
|
192
|
+
if (error instanceof Error && error.message === 'ACTION_TIMEOUT')
|
|
193
|
+
throw error;
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
process.once('exit', () => {
|
|
198
|
+
for (const bridge of active.values()) {
|
|
199
|
+
spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'shell', 'am', 'force-stop', packageName], { timeout: 2000, stdio: 'ignore' });
|
|
200
|
+
spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'forward', '--remove', `tcp:${bridge.hostPort}`], { timeout: 2000, stdio: 'ignore' });
|
|
201
|
+
}
|
|
202
|
+
});
|
package/dist/observe/android.js
CHANGED
|
@@ -8,17 +8,28 @@ import { computeScreenFingerprint } from "../utils/ui/index.js";
|
|
|
8
8
|
import { parsePngSize } from "../utils/image.js";
|
|
9
9
|
import { deriveSnapshotMetadata } from "./snapshot-metadata.js";
|
|
10
10
|
import { remainingBudget } from '../utils/operation-budget.js';
|
|
11
|
+
import { createObservationMeasurement } from './performance-metrics.js';
|
|
12
|
+
import { androidTreeBridgeEnabled, readAndroidTreeFromBridge } from './android-tree-bridge.js';
|
|
11
13
|
const activeLogStreams = new Map();
|
|
12
14
|
export class AndroidObserve {
|
|
13
15
|
async getDeviceMetadata(appId, deviceId) {
|
|
14
16
|
return getAndroidDeviceMetadata(appId, deviceId);
|
|
15
17
|
}
|
|
16
18
|
async getUITree(deviceId) {
|
|
17
|
-
const
|
|
19
|
+
const measurement = createObservationMeasurement('android');
|
|
20
|
+
let metadata;
|
|
21
|
+
try {
|
|
22
|
+
metadata = await measurement.timed('metadata_ms', () => getAndroidDeviceMetadata("", deviceId));
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
measurement.setOutcome('unavailable');
|
|
26
|
+
measurement.finish();
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
18
29
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
19
30
|
try {
|
|
20
31
|
// Get screen resolution first
|
|
21
|
-
const resolution = await getScreenResolution(deviceId);
|
|
32
|
+
const resolution = await measurement.timed('resolution_ms', () => getScreenResolution(deviceId));
|
|
22
33
|
if (resolution.width === 0 && resolution.height === 0) {
|
|
23
34
|
throw new Error("Failed to get screen resolution. Is the device connected and authorized?");
|
|
24
35
|
}
|
|
@@ -30,20 +41,38 @@ export class AndroidObserve {
|
|
|
30
41
|
remainingBudget();
|
|
31
42
|
attempts++;
|
|
32
43
|
try {
|
|
33
|
-
//
|
|
34
|
-
|
|
44
|
+
// The first read starts immediately so it remains fresh for in-place UI changes.
|
|
45
|
+
// Delay only a retry after a failed or invalid acquisition.
|
|
46
|
+
if (attempts > 1) {
|
|
47
|
+
await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
|
|
48
|
+
}
|
|
35
49
|
remainingBudget();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
let bridgeContent = null;
|
|
51
|
+
if (androidTreeBridgeEnabled()) {
|
|
52
|
+
try {
|
|
53
|
+
bridgeContent = await measurement.physicalRead(async () => {
|
|
54
|
+
const content = await readAndroidTreeFromBridge(deviceInfo.id);
|
|
55
|
+
if (!content)
|
|
56
|
+
throw new Error('Android UI tree bridge unavailable');
|
|
57
|
+
return content;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error instanceof Error && (error.message === 'ACTION_TIMEOUT' || error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')))
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
43
64
|
}
|
|
65
|
+
xmlContent = bridgeContent ?? await measurement.physicalRead(async () => {
|
|
66
|
+
await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
|
|
67
|
+
const content = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId);
|
|
68
|
+
if (!content || !content.trim() || content.includes('ERROR:'))
|
|
69
|
+
throw new Error('Empty or invalid UI dump');
|
|
70
|
+
return content;
|
|
71
|
+
});
|
|
72
|
+
break;
|
|
44
73
|
}
|
|
45
74
|
catch (e) {
|
|
46
|
-
if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
|
|
75
|
+
if (e instanceof Error && (e.message === 'ACTION_TIMEOUT' || e.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')))
|
|
47
76
|
throw e;
|
|
48
77
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
49
78
|
}
|
|
@@ -55,7 +84,7 @@ export class AndroidObserve {
|
|
|
55
84
|
ignoreAttributes: false,
|
|
56
85
|
attributeNamePrefix: "@_"
|
|
57
86
|
});
|
|
58
|
-
const result = parser.parse(xmlContent);
|
|
87
|
+
const result = await measurement.timed('parse_ms', async () => parser.parse(xmlContent));
|
|
59
88
|
const elements = [];
|
|
60
89
|
// The root is usually hierarchy -> node
|
|
61
90
|
if (result.hierarchy && result.hierarchy.node) {
|
|
@@ -72,18 +101,23 @@ export class AndroidObserve {
|
|
|
72
101
|
resolution,
|
|
73
102
|
elements
|
|
74
103
|
}, 'ui_tree');
|
|
75
|
-
|
|
104
|
+
measurement.setOutcome('observed');
|
|
105
|
+
const response = measurement.bindObservation({
|
|
76
106
|
device: deviceInfo,
|
|
77
107
|
screen: "",
|
|
78
108
|
resolution,
|
|
79
109
|
elements,
|
|
80
110
|
...snapshotMetadata
|
|
81
|
-
};
|
|
111
|
+
});
|
|
112
|
+
measurement.finish();
|
|
113
|
+
return response;
|
|
82
114
|
}
|
|
83
115
|
catch (e) {
|
|
84
116
|
const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
85
117
|
console.error(errorMessage);
|
|
86
118
|
const snapshotMetadata = deriveSnapshotMetadata(`android:${deviceInfo.id}`, null, 'ui_tree');
|
|
119
|
+
measurement.setOutcome('unavailable');
|
|
120
|
+
measurement.finish();
|
|
87
121
|
return {
|
|
88
122
|
device: deviceInfo,
|
|
89
123
|
screen: "",
|
package/dist/observe/ios.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { remainingBudget } from '../utils/operation-budget.js';
|
|
3
|
+
import { execCmd } from '../utils/exec.js';
|
|
4
|
+
import { createObservationMeasurement } from './performance-metrics.js';
|
|
3
5
|
import { promises as fs } from "fs";
|
|
4
6
|
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js";
|
|
5
7
|
import { createWriteStream, promises as fsPromises } from 'fs';
|
|
@@ -483,11 +485,22 @@ export class iOSObserve {
|
|
|
483
485
|
}
|
|
484
486
|
}
|
|
485
487
|
async getUITree(deviceId = "booted") {
|
|
486
|
-
const
|
|
488
|
+
const measurement = createObservationMeasurement('ios');
|
|
489
|
+
let device;
|
|
490
|
+
try {
|
|
491
|
+
device = await measurement.timed('metadata_ms', () => getIOSDeviceMetadata(deviceId));
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
measurement.setOutcome('unavailable');
|
|
495
|
+
measurement.finish();
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
487
498
|
const deviceKey = `ios:${device.id}`;
|
|
488
|
-
const idbExists = await isIDBInstalled();
|
|
499
|
+
const idbExists = await measurement.timed('capability_check_ms', () => isIDBInstalled());
|
|
489
500
|
if (!idbExists) {
|
|
490
501
|
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree');
|
|
502
|
+
measurement.setOutcome('unavailable');
|
|
503
|
+
measurement.finish();
|
|
491
504
|
return {
|
|
492
505
|
device,
|
|
493
506
|
screen: "",
|
|
@@ -505,30 +518,24 @@ export class iOSObserve {
|
|
|
505
518
|
remainingBudget();
|
|
506
519
|
attempts++;
|
|
507
520
|
try {
|
|
508
|
-
await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100)));
|
|
521
|
+
await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
|
|
509
522
|
remainingBudget();
|
|
510
523
|
const args = ['ui', 'describe-all', '--json'];
|
|
511
524
|
if (targetUdid) {
|
|
512
525
|
args.push('--udid', targetUdid);
|
|
513
526
|
}
|
|
514
|
-
const output = await
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
reject(new Error(`idb failed (code ${code}): ${stderr.trim()}`));
|
|
524
|
-
}
|
|
525
|
-
else {
|
|
526
|
-
resolve(stdout);
|
|
527
|
-
}
|
|
528
|
-
});
|
|
527
|
+
const output = await measurement.physicalRead(async () => {
|
|
528
|
+
const result = await execCmd(getIdbCmd(), args, { timeout: remainingBudget() });
|
|
529
|
+
if (result.stderr === 'ACTION_TIMEOUT')
|
|
530
|
+
throw new Error('ACTION_TIMEOUT');
|
|
531
|
+
if (result.exitCode !== 0)
|
|
532
|
+
throw new Error(`idb failed (code ${result.exitCode}): ${result.stderr}`);
|
|
533
|
+
if (!result.stdout.trim())
|
|
534
|
+
throw new Error('Empty idb UI tree output');
|
|
535
|
+
return result.stdout;
|
|
529
536
|
});
|
|
530
537
|
if (output && output.trim().length > 0) {
|
|
531
|
-
jsonContent = JSON.parse(output);
|
|
538
|
+
jsonContent = await measurement.timed('parse_ms', async () => JSON.parse(output));
|
|
532
539
|
break; // Success
|
|
533
540
|
}
|
|
534
541
|
}
|
|
@@ -539,6 +546,8 @@ export class iOSObserve {
|
|
|
539
546
|
}
|
|
540
547
|
if (attempts === maxAttempts) {
|
|
541
548
|
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree');
|
|
549
|
+
measurement.setOutcome('unavailable');
|
|
550
|
+
measurement.finish();
|
|
542
551
|
return {
|
|
543
552
|
device,
|
|
544
553
|
screen: "",
|
|
@@ -550,15 +559,17 @@ export class iOSObserve {
|
|
|
550
559
|
}
|
|
551
560
|
}
|
|
552
561
|
try {
|
|
553
|
-
const elements =
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
562
|
+
const elements = await measurement.timed('conversion_ms', async () => {
|
|
563
|
+
const parsed = [];
|
|
564
|
+
if (Array.isArray(jsonContent)) {
|
|
565
|
+
for (const node of jsonContent)
|
|
566
|
+
traverseIDBNode(node, parsed);
|
|
557
567
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
568
|
+
else {
|
|
569
|
+
traverseIDBNode(jsonContent, parsed);
|
|
570
|
+
}
|
|
571
|
+
return parsed;
|
|
572
|
+
});
|
|
562
573
|
let width = 0;
|
|
563
574
|
let height = 0;
|
|
564
575
|
if (elements.length > 0) {
|
|
@@ -571,16 +582,21 @@ export class iOSObserve {
|
|
|
571
582
|
resolution: { width, height },
|
|
572
583
|
elements
|
|
573
584
|
}, 'ui_tree');
|
|
574
|
-
|
|
585
|
+
measurement.setOutcome('observed');
|
|
586
|
+
const response = measurement.bindObservation({
|
|
575
587
|
device,
|
|
576
588
|
screen: "",
|
|
577
589
|
resolution: { width, height },
|
|
578
590
|
elements,
|
|
579
591
|
...snapshotMetadata
|
|
580
|
-
};
|
|
592
|
+
});
|
|
593
|
+
measurement.finish();
|
|
594
|
+
return response;
|
|
581
595
|
}
|
|
582
596
|
catch (e) {
|
|
583
597
|
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree');
|
|
598
|
+
measurement.setOutcome('unavailable');
|
|
599
|
+
measurement.finish();
|
|
584
600
|
return {
|
|
585
601
|
device,
|
|
586
602
|
screen: "",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
const context = new AsyncLocalStorage();
|
|
4
|
+
const observationIds = new WeakMap();
|
|
5
|
+
export function withObservationContext(value, operation) {
|
|
6
|
+
if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1')
|
|
7
|
+
return operation();
|
|
8
|
+
return context.run({ purpose: value.purpose ?? context.getStore()?.purpose ?? 'unspecified', stepId: value.stepId ?? context.getStore()?.stepId }, operation);
|
|
9
|
+
}
|
|
10
|
+
export function recordObservationEvent(event) {
|
|
11
|
+
if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1')
|
|
12
|
+
return;
|
|
13
|
+
const current = context.getStore();
|
|
14
|
+
process.stderr.write(`${JSON.stringify({
|
|
15
|
+
event: 'ui_tree_metric',
|
|
16
|
+
host_monotonic_ns: process.hrtime.bigint().toString(),
|
|
17
|
+
type: 'action_observation',
|
|
18
|
+
step_id: current?.stepId,
|
|
19
|
+
purpose: current?.purpose ?? 'unspecified',
|
|
20
|
+
...event
|
|
21
|
+
})}\n`);
|
|
22
|
+
}
|
|
23
|
+
export function recordObservationConsumer(observation, purpose, stepId) {
|
|
24
|
+
if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1' || !observation || typeof observation !== 'object')
|
|
25
|
+
return;
|
|
26
|
+
const acquisitionId = observationIds.get(observation);
|
|
27
|
+
if (!acquisitionId)
|
|
28
|
+
return;
|
|
29
|
+
const current = context.getStore();
|
|
30
|
+
process.stderr.write(`${JSON.stringify({
|
|
31
|
+
event: 'ui_tree_metric',
|
|
32
|
+
host_monotonic_ns: process.hrtime.bigint().toString(),
|
|
33
|
+
type: 'observation_consumer',
|
|
34
|
+
acquisition_id: acquisitionId,
|
|
35
|
+
consumer_step_id: stepId ?? current?.stepId,
|
|
36
|
+
consumer_purpose: purpose,
|
|
37
|
+
reused_observations: 1
|
|
38
|
+
})}\n`);
|
|
39
|
+
}
|
|
40
|
+
export function createObservationMeasurement(platform) {
|
|
41
|
+
const enabled = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS === '1';
|
|
42
|
+
const startedAt = enabled ? performance.now() : 0;
|
|
43
|
+
const id = enabled ? randomUUID() : undefined;
|
|
44
|
+
const current = context.getStore();
|
|
45
|
+
const durations = {};
|
|
46
|
+
let successfulOutput = false;
|
|
47
|
+
let outcome = 'unavailable';
|
|
48
|
+
let readAttempt = 0;
|
|
49
|
+
const emit = (event) => {
|
|
50
|
+
if (!enabled)
|
|
51
|
+
return;
|
|
52
|
+
process.stderr.write(`${JSON.stringify({
|
|
53
|
+
event: 'ui_tree_metric',
|
|
54
|
+
host_monotonic_ns: process.hrtime.bigint().toString(),
|
|
55
|
+
acquisition_id: id,
|
|
56
|
+
platform,
|
|
57
|
+
step_id: current?.stepId,
|
|
58
|
+
purpose: current?.purpose ?? 'unspecified',
|
|
59
|
+
...event
|
|
60
|
+
})}\n`);
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
bindObservation(observation) {
|
|
65
|
+
if (enabled && id)
|
|
66
|
+
observationIds.set(observation, id);
|
|
67
|
+
return observation;
|
|
68
|
+
},
|
|
69
|
+
timed: async (stage, operation) => {
|
|
70
|
+
if (!enabled)
|
|
71
|
+
return operation();
|
|
72
|
+
const start = performance.now();
|
|
73
|
+
let success = true;
|
|
74
|
+
try {
|
|
75
|
+
return await operation();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
success = false;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
const elapsed = performance.now() - start;
|
|
83
|
+
durations[stage] = (durations[stage] ?? 0) + elapsed;
|
|
84
|
+
emit({ type: 'stage', stage, attempt_index: stage === 'parse_ms' ? readAttempt : undefined, elapsed_ms: elapsed, success });
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
physicalRead: async (operation) => {
|
|
88
|
+
if (!enabled)
|
|
89
|
+
return operation();
|
|
90
|
+
const start = performance.now();
|
|
91
|
+
readAttempt++;
|
|
92
|
+
emit({ type: 'physical_read_started', attempt_index: readAttempt });
|
|
93
|
+
try {
|
|
94
|
+
const result = await operation();
|
|
95
|
+
successfulOutput = true;
|
|
96
|
+
emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: true });
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: false });
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
setOutcome(value) { if (enabled)
|
|
105
|
+
outcome = value; },
|
|
106
|
+
finish() {
|
|
107
|
+
if (!enabled)
|
|
108
|
+
return;
|
|
109
|
+
emit({
|
|
110
|
+
type: 'observation_consumer',
|
|
111
|
+
consumer_step_id: current?.stepId,
|
|
112
|
+
consumer_purpose: current?.purpose ?? 'unspecified',
|
|
113
|
+
reused_observations: 0
|
|
114
|
+
});
|
|
115
|
+
emit({
|
|
116
|
+
type: 'acquisition_completed',
|
|
117
|
+
elapsed_ms: performance.now() - startedAt,
|
|
118
|
+
successful_output: successfulOutput,
|
|
119
|
+
outcome,
|
|
120
|
+
durations_ms: durations
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|