mobile-debug-mcp 0.32.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +1 -0
  2. package/dist/observe/android-tree-bridge.js +192 -0
  3. package/dist/observe/android.js +49 -15
  4. package/dist/observe/ios.js +45 -29
  5. package/dist/observe/performance-metrics.js +124 -0
  6. package/dist/server/tool-handlers.js +31 -9
  7. package/dist/server-core.js +1 -1
  8. package/dist/utils/exec.js +15 -4
  9. package/docs/CHANGELOG.md +3 -0
  10. package/docs/ROADMAP.md +9 -0
  11. package/docs/benchmarks/ui-tree-performance/README.md +51 -0
  12. package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
  13. package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
  14. package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
  15. package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
  16. package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
  17. package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
  18. package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
  19. package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
  20. package/docs/tools/observe.md +1 -0
  21. package/package.json +1 -1
  22. package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
  23. package/src/observe/android-tree-bridge.ts +170 -0
  24. package/src/observe/android.ts +44 -17
  25. package/src/observe/ios.ts +45 -36
  26. package/src/observe/performance-metrics.ts +125 -0
  27. package/src/server/tool-handlers.ts +34 -10
  28. package/src/server-core.ts +1 -1
  29. package/src/utils/exec.ts +10 -4
  30. package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
  31. package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
  32. package/test/fixtures/latency-journey-app/AndroidManifest.xml +2 -1
  33. package/test/fixtures/latency-journey-app/README.md +19 -1
  34. package/test/fixtures/latency-journey-app/src/MainActivity.java +57 -1
  35. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
  36. package/test/fixtures/ui-tree-bridge/build.ts +34 -0
  37. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
  38. package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
  39. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
  40. package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
  41. package/test/unit/observe/performance_metrics.test.ts +40 -0
  42. package/test/unit/server/operation_budget.test.ts +13 -0
  43. package/test/unit/server/run_journey.test.ts +30 -0
  44. /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
  45. /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
  46. /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
  47. /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
  48. /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
  49. /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
  50. /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
  51. /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
  52. /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
  53. /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
  54. /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
  55. /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
  56. /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
  57. /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
  58. /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
  59. /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
  60. /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
  61. /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
+ [![npm version](https://img.shields.io/npm/v/mobile-debug-mcp.svg)](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,192 @@
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
+ export function androidTreeBridgeEnabled() {
14
+ return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK);
15
+ }
16
+ function configuredApk() {
17
+ const configured = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK;
18
+ if (!configured)
19
+ return null;
20
+ const apk = path.resolve(configured);
21
+ if (!existsSync(apk))
22
+ throw new Error(`Android UI tree bridge APK does not exist: ${apk}`);
23
+ return apk;
24
+ }
25
+ function unbudgetedAdb(deviceId, args) {
26
+ return new Promise((resolve, reject) => {
27
+ execFile(getAdbCmd(), ['-s', deviceId, ...args], { timeout: 5000 }, (error) => error ? reject(error) : resolve());
28
+ });
29
+ }
30
+ function request(bridge, command, timeoutMs) {
31
+ return new Promise((resolve, reject) => {
32
+ let output = '';
33
+ const socket = connect(bridge.hostPort, '127.0.0.1', () => socket.write(`${command}\n`));
34
+ socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`Android UI tree bridge ${command} timed out`)));
35
+ socket.on('data', (chunk) => {
36
+ output += chunk.toString();
37
+ if (command === 'STOP' && output.startsWith('OK\n')) {
38
+ resolve(output);
39
+ socket.destroy();
40
+ }
41
+ });
42
+ socket.on('error', reject);
43
+ socket.on('end', () => resolve(output));
44
+ });
45
+ }
46
+ function waitForChildClose(bridge, timeoutMs) {
47
+ if (bridge.childClosed)
48
+ return Promise.resolve();
49
+ return new Promise((resolve, reject) => {
50
+ const onClose = () => { clearTimeout(timer); resolve(); };
51
+ const timer = setTimeout(() => {
52
+ bridge.child.off('close', onClose);
53
+ reject(new Error('Android UI tree bridge child did not exit'));
54
+ }, timeoutMs);
55
+ bridge.child.once('close', onClose);
56
+ });
57
+ }
58
+ async function stop(bridge) {
59
+ const pending = stopping.get(bridge.deviceId);
60
+ if (pending)
61
+ return pending;
62
+ bridge.closed = true;
63
+ const cleanup = (async () => {
64
+ await request(bridge, 'STOP', 1000).catch(() => '');
65
+ let forceStopError;
66
+ try {
67
+ await unbudgetedAdb(bridge.deviceId, ['shell', 'am', 'force-stop', packageName]);
68
+ }
69
+ catch (error) {
70
+ forceStopError = error;
71
+ }
72
+ try {
73
+ await waitForChildClose(bridge, 1000);
74
+ }
75
+ catch {
76
+ bridge.child.kill('SIGTERM');
77
+ try {
78
+ await waitForChildClose(bridge, 1000);
79
+ }
80
+ catch {
81
+ bridge.child.kill('SIGKILL');
82
+ await waitForChildClose(bridge, 2000).catch(() => { });
83
+ }
84
+ }
85
+ let forwardError;
86
+ try {
87
+ await unbudgetedAdb(bridge.deviceId, ['forward', '--remove', `tcp:${bridge.hostPort}`]);
88
+ }
89
+ catch (error) {
90
+ forwardError = error;
91
+ }
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)
97
+ active.delete(bridge.deviceId);
98
+ })();
99
+ stopping.set(bridge.deviceId, cleanup);
100
+ try {
101
+ await cleanup;
102
+ }
103
+ finally {
104
+ stopping.delete(bridge.deviceId);
105
+ }
106
+ }
107
+ async function launch(deviceId, apk) {
108
+ await unbudgetedAdb(deviceId, ['shell', 'am', 'force-stop', packageName]);
109
+ try {
110
+ await execAdb(['install', '-r', apk], deviceId);
111
+ }
112
+ catch (error) {
113
+ if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE'))
114
+ throw error;
115
+ await execAdb(['uninstall', packageName], deviceId);
116
+ await execAdb(['install', '-r', apk], deviceId);
117
+ }
118
+ const hostPort = Number(await execAdb(['forward', 'tcp:0', `tcp:${devicePort}`], deviceId));
119
+ if (!Number.isInteger(hostPort) || hostPort <= 0)
120
+ throw new Error('Android UI tree bridge could not obtain a host port');
121
+ const child = spawn(getAdbCmd(), ['-s', deviceId, 'shell', 'am', 'instrument', '-w', component], { stdio: 'ignore' });
122
+ const bridge = { deviceId, hostPort, child, closed: false, childClosed: false };
123
+ child.once('close', () => { bridge.childClosed = true; });
124
+ child.once('error', () => { });
125
+ active.set(deviceId, bridge);
126
+ try {
127
+ const deadline = Date.now() + Math.min(remainingBudget(3000), 3000);
128
+ while (Date.now() < deadline) {
129
+ if (bridge.childClosed || child.exitCode !== null || child.signalCode !== null)
130
+ throw new Error('Android UI tree bridge exited during startup');
131
+ try {
132
+ if ((await request(bridge, 'PING', 250)).startsWith('OK\n')) {
133
+ return bridge;
134
+ }
135
+ }
136
+ catch { /* The instrumentation process may still be starting. */ }
137
+ await new Promise((resolve) => setTimeout(resolve, 100));
138
+ }
139
+ throw new Error('Android UI tree bridge did not become ready');
140
+ }
141
+ catch (error) {
142
+ await stop(bridge);
143
+ throw error;
144
+ }
145
+ }
146
+ async function ensure(deviceId, apk) {
147
+ const pending = starting.get(deviceId);
148
+ if (pending)
149
+ return pending;
150
+ const ready = active.get(deviceId);
151
+ if (ready && !ready.closed && !ready.childClosed && ready.child.exitCode === null && ready.child.signalCode === null)
152
+ return ready;
153
+ if (ready)
154
+ await stop(ready);
155
+ const promise = launch(deviceId, apk);
156
+ starting.set(deviceId, promise);
157
+ try {
158
+ return await promise;
159
+ }
160
+ finally {
161
+ starting.delete(deviceId);
162
+ }
163
+ }
164
+ /** Returns null only after the bridge has been stopped, so legacy acquisition is safe. */
165
+ export async function readAndroidTreeFromBridge(deviceId) {
166
+ const apk = configuredApk();
167
+ if (!apk)
168
+ return null;
169
+ let bridge;
170
+ try {
171
+ bridge = await ensure(deviceId, apk);
172
+ const output = await request(bridge, 'TREE', remainingBudget(10000));
173
+ if (!output.startsWith('OK\n') || !output.includes('<hierarchy'))
174
+ throw new Error('Android UI tree bridge returned invalid hierarchy output');
175
+ return output.slice(3);
176
+ }
177
+ catch (error) {
178
+ if (error instanceof Error && error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))
179
+ throw error;
180
+ if (bridge)
181
+ await stop(bridge);
182
+ if (error instanceof Error && error.message === 'ACTION_TIMEOUT')
183
+ throw error;
184
+ return null;
185
+ }
186
+ }
187
+ process.once('exit', () => {
188
+ for (const bridge of active.values()) {
189
+ spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'shell', 'am', 'force-stop', packageName], { timeout: 2000, stdio: 'ignore' });
190
+ spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'forward', '--remove', `tcp:${bridge.hostPort}`], { timeout: 2000, stdio: 'ignore' });
191
+ }
192
+ });
@@ -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 metadata = await getAndroidDeviceMetadata("", deviceId);
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
- // Stabilization delay
34
- await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))); // 300ms, 400ms, 500ms...
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
- // Dump UI hierarchy
37
- await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
38
- // Read the file
39
- xmlContent = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId);
40
- // Check validity
41
- if (xmlContent && xmlContent.trim().length > 0 && !xmlContent.includes("ERROR:")) {
42
- break; // Success
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
- return {
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: "",
@@ -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 device = await getIOSDeviceMetadata(deviceId);
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 new Promise((resolve, reject) => {
515
- const child = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
516
- let stdout = '';
517
- let stderr = '';
518
- child.stdout.on('data', (data) => stdout += data.toString());
519
- child.stderr.on('data', (data) => stderr += data.toString());
520
- child.on('error', (err) => reject(new Error(`Failed to execute idb: ${err.message}`)));
521
- child.on('close', (code) => {
522
- if (code !== 0) {
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
- if (Array.isArray(jsonContent)) {
555
- for (const node of jsonContent) {
556
- traverseIDBNode(node, elements);
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
- else {
560
- traverseIDBNode(jsonContent, elements);
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
- return {
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
+ }