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
@@ -1,10 +1,16 @@
1
1
  package dev.mobiledebugmcp.latencyfixture;
2
2
 
3
3
  import android.app.Activity;
4
+ import android.content.Intent;
4
5
  import android.os.Bundle;
5
6
  import android.widget.Button;
6
7
  import android.widget.LinearLayout;
7
8
  import android.widget.TextView;
9
+ import java.io.BufferedReader;
10
+ import java.io.InputStreamReader;
11
+ import java.net.HttpURLConnection;
12
+ import java.net.URL;
13
+ import java.net.URLEncoder;
8
14
 
9
15
  public final class MainActivity extends Activity {
10
16
  private static final class Selectors {
@@ -20,7 +26,57 @@ public final class MainActivity extends Activity {
20
26
  }
21
27
  private boolean active = false;
22
28
  private LinearLayout layout;
23
- @Override public void onCreate(Bundle state) { super.onCreate(state); home(); }
29
+ @Override public void onCreate(Bundle state) {
30
+ super.onCreate(state);
31
+ String networkBaseUrl = getIntent().getStringExtra("networkBaseUrl");
32
+ String networkRunId = getIntent().getStringExtra("networkRunId");
33
+ if (networkBaseUrl != null && networkRunId != null) networkScreen(networkBaseUrl, networkRunId);
34
+ else home();
35
+ }
36
+ private void networkScreen(String baseUrl, String runId) {
37
+ layout = new LinearLayout(this);
38
+ layout.setOrientation(LinearLayout.VERTICAL);
39
+ layout.setPadding(32, 100, 32, 32);
40
+ setContentView(layout);
41
+ TextView loading = new TextView(this);
42
+ loading.setText("Loading");
43
+ loading.setContentDescription("loading");
44
+ layout.addView(loading);
45
+ TextView ready = new TextView(this);
46
+ ready.setText("Ready");
47
+ ready.setContentDescription("ready");
48
+ ready.setVisibility(android.view.View.GONE);
49
+ layout.addView(ready);
50
+ TextView value = new TextView(this);
51
+ value.setContentDescription("value");
52
+ value.setVisibility(android.view.View.GONE);
53
+ layout.addView(value);
54
+ new Thread(() -> {
55
+ HttpURLConnection connection = null;
56
+ try {
57
+ String encodedRunId = URLEncoder.encode(runId, "UTF-8");
58
+ connection = (HttpURLConnection) new URL(baseUrl + "/data?run_id=" + encodedRunId).openConnection();
59
+ connection.setConnectTimeout(10000);
60
+ connection.setReadTimeout(30000);
61
+ String response;
62
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
63
+ response = reader.readLine();
64
+ }
65
+ String[] fields = response == null ? new String[0] : response.split("\\|", 2);
66
+ if (fields.length == 2 && runId.equals(fields[0])) {
67
+ runOnUiThread(() -> {
68
+ ready.setVisibility(android.view.View.VISIBLE);
69
+ value.setText(fields[1]);
70
+ value.setVisibility(android.view.View.VISIBLE);
71
+ });
72
+ }
73
+ } catch (Exception ignored) {
74
+ // A failed local fixture request leaves the screen in its observable loading state.
75
+ } finally {
76
+ if (connection != null) connection.disconnect();
77
+ }
78
+ }).start();
79
+ }
24
80
  private void screen(String id) {
25
81
  layout = new LinearLayout(this);
26
82
  layout.setOrientation(LinearLayout.VERTICAL);
@@ -0,0 +1,6 @@
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>
@@ -0,0 +1,34 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ export function buildBridge() {
7
+ const root = path.dirname(fileURLToPath(import.meta.url))
8
+ const out = path.join(root, 'build')
9
+ const sdk = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME ||
10
+ (process.platform === 'darwin' && process.env.HOME ? path.join(process.env.HOME, 'Library/Android/sdk') : '')
11
+ if (!sdk) throw new Error('Android SDK not found: set ANDROID_SDK_ROOT or ANDROID_HOME')
12
+ const buildTools = path.join(sdk, 'build-tools', '36.0.0')
13
+ const androidJar = path.join(sdk, 'platforms', 'android-36', 'android.jar')
14
+ if (!existsSync(androidJar) || !existsSync(buildTools)) {
15
+ throw new Error(`Android SDK 36 platform and build-tools 36.0.0 are required under ${sdk}`)
16
+ }
17
+ const java = process.env.JAVA_HOME
18
+ const tool = (name: string) => java ? path.join(java, 'bin', name) : name
19
+ const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { cwd: out, stdio: 'pipe' })
20
+ mkdirSync(path.join(out, 'classes'), { recursive: true })
21
+ run(tool('javac'), ['--release', '8', '-classpath', androidJar, '-d', 'classes', path.join(root, 'src/TreeInstrumentation.java')])
22
+ run(tool('jar'), ['cf', 'classes.jar', '-C', 'classes', '.'])
23
+ run(path.join(buildTools, 'd8'), ['--lib', androidJar, '--output', out, 'classes.jar'])
24
+ run(path.join(buildTools, 'aapt2'), ['link', '-I', androidJar, '--manifest', path.join(root, 'AndroidManifest.xml'), '-o', 'unsigned.apk'])
25
+ run('zip', ['-q', 'unsigned.apk', 'classes.dex'])
26
+ run(path.join(buildTools, 'zipalign'), ['-f', '4', 'unsigned.apk', 'aligned.apk'])
27
+ try { run(tool('keytool'), ['-genkeypair', '-keystore', 'fixture.jks', '-storepass', 'android', '-keypass', 'android', '-alias', 'fixture', '-dname', 'CN=UI Tree Bridge', '-keyalg', 'RSA', '-validity', '3650']) } catch { /* Existing local fixture key. */ }
28
+ run(path.join(buildTools, 'apksigner'), ['sign', '--ks', 'fixture.jks', '--ks-pass', 'pass:android', '--out', 'bridge.apk', 'aligned.apk'])
29
+ return path.join(out, 'bridge.apk')
30
+ }
31
+
32
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
33
+ console.log(buildBridge())
34
+ }
@@ -0,0 +1,119 @@
1
+ package dev.mobiledebugmcp.treebridge;
2
+
3
+ import android.app.Instrumentation;
4
+ import android.app.UiAutomation;
5
+ import android.graphics.Rect;
6
+ import android.os.Bundle;
7
+ import android.util.Xml;
8
+ import android.util.Log;
9
+ import android.view.accessibility.AccessibilityNodeInfo;
10
+ import java.io.BufferedReader;
11
+ import java.io.InputStreamReader;
12
+ import java.io.OutputStreamWriter;
13
+ import java.io.StringWriter;
14
+ import java.net.InetAddress;
15
+ import java.net.ServerSocket;
16
+ import java.net.Socket;
17
+ import org.xmlpull.v1.XmlSerializer;
18
+
19
+ /** Benchmark-only bridge: one live accessibility acquisition per TREE request. */
20
+ public final class TreeInstrumentation extends Instrumentation {
21
+ private static final int PORT = 39019;
22
+
23
+ @Override public void onCreate(Bundle arguments) {
24
+ super.onCreate(arguments);
25
+ start();
26
+ }
27
+
28
+ @Override public void onStart() {
29
+ try (ServerSocket server = new ServerSocket(PORT, 1, InetAddress.getByName("127.0.0.1"))) {
30
+ UiAutomation automation = getUiAutomation(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES);
31
+ while (true) {
32
+ try (Socket socket = server.accept()) {
33
+ socket.setSoTimeout(10000);
34
+ BufferedReader input = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
35
+ OutputStreamWriter output = new OutputStreamWriter(socket.getOutputStream(), "UTF-8");
36
+ String command = input.readLine();
37
+ if ("STOP".equals(command)) {
38
+ output.write("OK\n");
39
+ output.flush();
40
+ break;
41
+ }
42
+ if ("PING".equals(command)) {
43
+ output.write("OK\n");
44
+ output.flush();
45
+ continue;
46
+ }
47
+ if (!"TREE".equals(command)) {
48
+ output.write("ERROR invalid_command\n");
49
+ } else {
50
+ AccessibilityNodeInfo root = automation.getRootInActiveWindow();
51
+ if (root == null) {
52
+ output.write("ERROR null_root\n");
53
+ } else {
54
+ output.write("OK\n");
55
+ output.write(serialize(root));
56
+ root.recycle();
57
+ }
58
+ }
59
+ output.flush();
60
+ } catch (Exception error) {
61
+ // A failed client request cannot make a later acquisition reuse its tree.
62
+ Log.e("TreeBridge", "request failed", error);
63
+ }
64
+ }
65
+ } catch (Exception error) {
66
+ // The host-side probe detects failure to start or connect.
67
+ Log.e("TreeBridge", "bridge failed", error);
68
+ }
69
+ finish(0, new Bundle());
70
+ }
71
+
72
+ private static String serialize(AccessibilityNodeInfo root) throws Exception {
73
+ StringWriter writer = new StringWriter();
74
+ XmlSerializer xml = Xml.newSerializer();
75
+ xml.setOutput(writer);
76
+ xml.startDocument("UTF-8", true);
77
+ xml.startTag("", "hierarchy");
78
+ writeNode(xml, root, 0);
79
+ xml.endTag("", "hierarchy");
80
+ xml.endDocument();
81
+ return writer.toString();
82
+ }
83
+
84
+ private static void writeNode(XmlSerializer xml, AccessibilityNodeInfo node, int index) throws Exception {
85
+ Rect bounds = new Rect();
86
+ node.getBoundsInScreen(bounds);
87
+ xml.startTag("", "node");
88
+ xml.attribute("", "index", Integer.toString(index));
89
+ xml.attribute("", "class", value(node.getClassName()));
90
+ xml.attribute("", "package", value(node.getPackageName()));
91
+ xml.attribute("", "text", value(node.getText()));
92
+ xml.attribute("", "content-desc", value(node.getContentDescription()));
93
+ xml.attribute("", "resource-id", value(node.getViewIdResourceName()));
94
+ xml.attribute("", "checkable", Boolean.toString(node.isCheckable()));
95
+ xml.attribute("", "checked", Boolean.toString(node.isChecked()));
96
+ xml.attribute("", "clickable", Boolean.toString(node.isClickable()));
97
+ xml.attribute("", "enabled", Boolean.toString(node.isEnabled()));
98
+ xml.attribute("", "focusable", Boolean.toString(node.isFocusable()));
99
+ xml.attribute("", "focused", Boolean.toString(node.isFocused()));
100
+ xml.attribute("", "scrollable", Boolean.toString(node.isScrollable()));
101
+ xml.attribute("", "long-clickable", Boolean.toString(node.isLongClickable()));
102
+ xml.attribute("", "password", Boolean.toString(node.isPassword()));
103
+ xml.attribute("", "selected", Boolean.toString(node.isSelected()));
104
+ xml.attribute("", "visible-to-user", Boolean.toString(node.isVisibleToUser()));
105
+ xml.attribute("", "bounds", bounds.toShortString());
106
+ for (int childIndex = 0; childIndex < node.getChildCount(); childIndex++) {
107
+ AccessibilityNodeInfo child = node.getChild(childIndex);
108
+ if (child != null && child.isVisibleToUser()) {
109
+ writeNode(xml, child, childIndex);
110
+ }
111
+ if (child != null) child.recycle();
112
+ }
113
+ xml.endTag("", "node");
114
+ }
115
+
116
+ private static String value(CharSequence input) {
117
+ return input == null ? "" : input.toString();
118
+ }
119
+ }
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert/strict'
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { AndroidObserve } from '../../../src/observe/android.js'
6
+ import { resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
7
+ import { runWithBudget } from '../../../src/utils/operation-budget.js'
8
+
9
+ async function run() {
10
+ const directory = mkdtempSync(path.join(tmpdir(), 'mcp-fake-adb-'))
11
+ const adbPath = path.join(directory, 'adb')
12
+ const previousAdb = process.env.ADB_PATH
13
+ const previousMetrics = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS
14
+ const previousFailure = process.env.MCP_FAKE_ADB_FAIL
15
+ const previousWrite = process.stderr.write
16
+ const lines: string[] = []
17
+ writeFileSync(adbPath, `#!/usr/bin/env node
18
+ const args = process.argv.slice(2).join(' ')
19
+ if (args.includes('ro.build.version.release')) console.log('16')
20
+ else if (args.includes('ro.product.model')) console.log('Fake Android')
21
+ else if (args.includes('ro.kernel.qemu')) console.log('1')
22
+ else if (args.includes('wm size')) console.log('Physical size: 100x200')
23
+ else if (args.includes('uiautomator dump')) { if (process.env.MCP_FAKE_ADB_FAIL === '1') process.exit(1); console.log('UI hierarchy dumped') }
24
+ else if (args.includes('cat /sdcard/ui.xml')) console.log('<hierarchy><node class="android.widget.TextView" text="Ready" bounds="[0,0][50,50]" /></hierarchy>')
25
+ else process.exit(1)
26
+ `)
27
+ chmodSync(adbPath, 0o755)
28
+ process.env.ADB_PATH = adbPath
29
+ process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS = '1'
30
+ ;(process.stderr as any).write = (chunk: string) => { lines.push(chunk); return true }
31
+ resetSnapshotMetadataForTests()
32
+ try {
33
+ const observe = new AndroidObserve()
34
+ const first = await observe.getUITree('fake-a')
35
+ const second = await observe.getUITree('fake-b')
36
+ assert.equal(first.error, undefined)
37
+ assert.equal(second.error, undefined)
38
+ assert.equal(first.snapshot_revision, 1)
39
+ assert.notEqual(first.snapshot_revision, second.snapshot_revision)
40
+ assert.equal(second.snapshot_delta, null, 'different devices keep independent delta history')
41
+ assert.equal(first.elements[0].text, 'Ready')
42
+ process.env.MCP_FAKE_ADB_FAIL = '1'
43
+ const failed = await runWithBudget(1000, () => observe.getUITree('fake-a'))
44
+ assert.ok(failed.error)
45
+ delete process.env.MCP_FAKE_ADB_FAIL
46
+ const recovered = await observe.getUITree('fake-a')
47
+ assert.equal(recovered.error, undefined, 'the next request takes a fresh physical read')
48
+ const events = lines.filter((line) => line.startsWith('{')).map((line) => JSON.parse(line))
49
+ const started = events.filter((event) => event.type === 'physical_read_started')
50
+ const completed = events.filter((event) => event.type === 'physical_read_completed')
51
+ assert.equal(started.length, completed.length)
52
+ assert.ok(started.length >= 4)
53
+ assert.equal(events.filter((event) => event.type === 'physical_read_completed' && event.success).length, 3)
54
+ assert.ok(events.some((event) => event.type === 'physical_read_completed' && !event.success))
55
+ const successfulAcquisitions = new Set(events.filter((event) => event.type === 'acquisition_completed' && event.outcome === 'observed').map((event) => event.acquisition_id))
56
+ assert.equal(events.filter((event) => event.stage === 'observation_wait_ms' && successfulAcquisitions.has(event.acquisition_id)).length, 0, 'successful first attempts have no pre-read wait')
57
+ } finally {
58
+ ;(process.stderr as any).write = previousWrite
59
+ if (previousAdb === undefined) delete process.env.ADB_PATH
60
+ else process.env.ADB_PATH = previousAdb
61
+ if (previousMetrics === undefined) delete process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS
62
+ else process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS = previousMetrics
63
+ if (previousFailure === undefined) delete process.env.MCP_FAKE_ADB_FAIL
64
+ else process.env.MCP_FAKE_ADB_FAIL = previousFailure
65
+ rmSync(directory, { recursive: true, force: true })
66
+ resetSnapshotMetadataForTests()
67
+ }
68
+ }
69
+
70
+ run().catch((error) => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,76 @@
1
+ import assert from 'node:assert/strict'
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { createServer } from 'node:net'
4
+ import type { Socket } from 'node:net'
5
+ import { tmpdir } from 'node:os'
6
+ import path from 'node:path'
7
+ import { readAndroidTreeFromBridge } from '../../../src/observe/android-tree-bridge.js'
8
+
9
+ async function run() {
10
+ const directory = mkdtempSync(path.join(tmpdir(), 'mcp-tree-bridge-'))
11
+ const adbPath = path.join(directory, 'adb')
12
+ const apkPath = path.join(directory, 'bridge.apk')
13
+ const launchesPath = path.join(directory, 'launches')
14
+ const previous = new Map(['ADB_PATH', 'MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK', 'MCP_FAKE_BRIDGE_PORT', 'MCP_FAKE_BRIDGE_LAUNCHES', 'MCP_FAKE_FORCE_STOP_FAIL'].map((key) => [key, process.env[key]]))
15
+ let invalidTree = false
16
+ const sockets = new Set<Socket>()
17
+ const server = createServer((socket) => {
18
+ sockets.add(socket)
19
+ socket.once('close', () => sockets.delete(socket))
20
+ socket.once('data', (chunk) => {
21
+ const command = chunk.toString().trim()
22
+ socket.end(command === 'TREE'
23
+ ? invalidTree ? 'ERROR null_root\n' : 'OK\n<hierarchy><node class="android.view.View" /></hierarchy>'
24
+ : 'OK\n')
25
+ })
26
+ })
27
+ writeFileSync(apkPath, '')
28
+ writeFileSync(launchesPath, '')
29
+ writeFileSync(adbPath, `#!/usr/bin/env node
30
+ const fs = require('node:fs')
31
+ const args = process.argv.slice(2).join(' ')
32
+ if (args.includes('am instrument')) {
33
+ fs.appendFileSync(process.env.MCP_FAKE_BRIDGE_LAUNCHES, 'launch\\n')
34
+ setInterval(() => {}, 1000)
35
+ } else if (args.includes('am force-stop') && process.env.MCP_FAKE_FORCE_STOP_FAIL === '1') {
36
+ process.exit(1)
37
+ } else if (args.includes('forward tcp:0')) {
38
+ console.log(process.env.MCP_FAKE_BRIDGE_PORT)
39
+ }
40
+ `)
41
+ chmodSync(adbPath, 0o755)
42
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
43
+ const address = server.address()
44
+ if (!address || typeof address === 'string') throw new Error('No test port')
45
+ process.env.ADB_PATH = adbPath
46
+ process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK = apkPath
47
+ process.env.MCP_FAKE_BRIDGE_PORT = String(address.port)
48
+ process.env.MCP_FAKE_BRIDGE_LAUNCHES = launchesPath
49
+ try {
50
+ assert.match(await readAndroidTreeFromBridge('test-device') ?? '', /<hierarchy>/)
51
+ invalidTree = true
52
+ process.env.MCP_FAKE_FORCE_STOP_FAIL = '1'
53
+ await assert.rejects(() => readAndroidTreeFromBridge('test-device'), /BRIDGE_CLEANUP_UNCONFIRMED/)
54
+ await assert.rejects(() => readAndroidTreeFromBridge('test-device'), /BRIDGE_CLEANUP_UNCONFIRMED/)
55
+ assert.equal(readFileSync(launchesPath, 'utf8').trim().split('\n').length, 1, 'failed cleanup must block a second launch')
56
+ invalidTree = false
57
+ delete process.env.MCP_FAKE_FORCE_STOP_FAIL
58
+ assert.match(await readAndroidTreeFromBridge('test-device') ?? '', /<hierarchy>/, 'successful cleanup allows relaunch')
59
+ for (let attempt = 0; attempt < 20 && readFileSync(launchesPath, 'utf8').trim().split('\n').length < 2; attempt++) {
60
+ await new Promise((resolve) => setTimeout(resolve, 50))
61
+ }
62
+ assert.equal(readFileSync(launchesPath, 'utf8').trim().split('\n').length, 2)
63
+ invalidTree = true
64
+ assert.equal(await readAndroidTreeFromBridge('test-device'), null, 'clean shutdown permits legacy fallback')
65
+ } finally {
66
+ for (const [key, value] of previous) {
67
+ if (value === undefined) delete process.env[key]
68
+ else process.env[key] = value
69
+ }
70
+ for (const socket of sockets) socket.destroy()
71
+ await new Promise<void>((resolve) => server.close(() => resolve()))
72
+ rmSync(directory, { recursive: true, force: true })
73
+ }
74
+ }
75
+
76
+ run().catch((error) => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,77 @@
1
+ import assert from 'node:assert/strict'
2
+ import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { iOSObserve } from '../../../src/observe/ios.js'
6
+ import { resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
7
+ import { runWithBudget } from '../../../src/utils/operation-budget.js'
8
+
9
+ async function run() {
10
+ const directory = mkdtempSync(path.join(tmpdir(), 'mcp-fake-idb-'))
11
+ const idbPath = path.join(directory, 'idb')
12
+ const xcrunPath = path.join(directory, 'xcrun')
13
+ const previousIdb = process.env.IDB_PATH
14
+ const previousXcrun = process.env.XCRUN_PATH
15
+ const previousMetrics = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS
16
+ const previousSlow = process.env.MCP_FAKE_IDB_SLOW
17
+ const previousMarker = process.env.MCP_FAKE_IDB_CLOSE_MARKER
18
+ const previousWrite = process.stderr.write
19
+ const lines: string[] = []
20
+ writeFileSync(idbPath, `#!/usr/bin/env node
21
+ const args = process.argv.slice(2).join(' ')
22
+ if (args.includes('list-targets')) console.log('[]')
23
+ else if (args.includes('ui describe-all')) {
24
+ if (process.env.MCP_FAKE_IDB_SLOW === '1') {
25
+ process.on('SIGTERM', () => setTimeout(() => { require('fs').writeFileSync(process.env.MCP_FAKE_IDB_CLOSE_MARKER, 'closed'); process.exit(0) }, 50))
26
+ setInterval(() => {}, 1000)
27
+ } else console.log(JSON.stringify({ AXElementType: 'Button', AXLabel: 'Ready', AXFrame: { x: 0, y: 0, width: 50, height: 50 } }))
28
+ }
29
+ else process.exit(1)
30
+ `)
31
+ writeFileSync(xcrunPath, `#!/usr/bin/env node
32
+ console.log(JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [{ udid: 'fake-a', name: 'Fake iPhone' }, { udid: 'fake-b', name: 'Fake iPhone' }] } }))
33
+ `)
34
+ chmodSync(idbPath, 0o755)
35
+ chmodSync(xcrunPath, 0o755)
36
+ process.env.IDB_PATH = idbPath
37
+ process.env.XCRUN_PATH = xcrunPath
38
+ process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS = '1'
39
+ ;(process.stderr as any).write = (chunk: string) => { lines.push(chunk); return true }
40
+ resetSnapshotMetadataForTests()
41
+ try {
42
+ const observe = new iOSObserve()
43
+ const first = await observe.getUITree('fake-a')
44
+ const second = await observe.getUITree('fake-b')
45
+ assert.equal(first.error, undefined)
46
+ assert.equal(second.error, undefined)
47
+ assert.equal(first.elements[0].text, 'Ready')
48
+ assert.equal(second.snapshot_delta, null, 'different simulators keep independent delta history')
49
+ const events = lines.map((line) => JSON.parse(line))
50
+ assert.equal(events.filter((event) => event.type === 'physical_read_started').length, 2)
51
+ assert.equal(events.filter((event) => event.type === 'physical_read_completed' && event.success).length, 2)
52
+ const marker = path.join(directory, 'idb-closed')
53
+ process.env.MCP_FAKE_IDB_SLOW = '1'
54
+ process.env.MCP_FAKE_IDB_CLOSE_MARKER = marker
55
+ await assert.rejects(() => runWithBudget(1200, () => observe.getUITree('fake-a')), /ACTION_TIMEOUT/)
56
+ assert.equal(existsSync(marker), true, 'budget expiry waits for idb child close')
57
+ delete process.env.MCP_FAKE_IDB_SLOW
58
+ const recovered = await observe.getUITree('fake-a')
59
+ assert.equal(recovered.error, undefined, 'a cancelled iOS observation is not reused')
60
+ } finally {
61
+ ;(process.stderr as any).write = previousWrite
62
+ if (previousIdb === undefined) delete process.env.IDB_PATH
63
+ else process.env.IDB_PATH = previousIdb
64
+ if (previousXcrun === undefined) delete process.env.XCRUN_PATH
65
+ else process.env.XCRUN_PATH = previousXcrun
66
+ if (previousMetrics === undefined) delete process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS
67
+ else process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS = previousMetrics
68
+ if (previousSlow === undefined) delete process.env.MCP_FAKE_IDB_SLOW
69
+ else process.env.MCP_FAKE_IDB_SLOW = previousSlow
70
+ if (previousMarker === undefined) delete process.env.MCP_FAKE_IDB_CLOSE_MARKER
71
+ else process.env.MCP_FAKE_IDB_CLOSE_MARKER = previousMarker
72
+ rmSync(directory, { recursive: true, force: true })
73
+ resetSnapshotMetadataForTests()
74
+ }
75
+ }
76
+
77
+ run().catch((error) => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createObservationMeasurement, recordObservationConsumer, withObservationContext } from '../../../src/observe/performance-metrics.js'
3
+
4
+ async function run() {
5
+ const previousFlag = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS
6
+ const previousWrite = process.stderr.write
7
+ const lines: string[] = []
8
+ process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS = '1'
9
+ ;(process.stderr as any).write = (chunk: string) => { lines.push(chunk); return true }
10
+ try {
11
+ const tree = { elements: [{ text: 'private UI value' }] }
12
+ await withObservationContext({ stepId: 'tap-1', purpose: 'target' }, async () => {
13
+ const measurement = createObservationMeasurement('android')
14
+ await measurement.physicalRead(async () => '<hierarchy/>')
15
+ measurement.bindObservation(tree)
16
+ measurement.setOutcome('observed')
17
+ measurement.finish()
18
+ recordObservationConsumer(tree, 'verification', 'tap-1')
19
+ })
20
+ const events = lines.map((line) => JSON.parse(line))
21
+ const starts = events.filter((event) => event.type === 'physical_read_started')
22
+ const completed = events.filter((event) => event.type === 'physical_read_completed')
23
+ const acquisitions = events.filter((event) => event.type === 'acquisition_completed')
24
+ const consumers = events.filter((event) => event.type === 'observation_consumer')
25
+ assert.equal(starts.length, 1)
26
+ assert.equal(completed.length, 1)
27
+ assert.equal(acquisitions.length, 1)
28
+ assert.equal(consumers.length, 2)
29
+ assert.ok(events.every((event) => event.acquisition_id === starts[0].acquisition_id))
30
+ assert.equal(consumers[1].consumer_purpose, 'verification')
31
+ assert.equal(consumers[1].reused_observations, 1)
32
+ assert.equal(lines.join('').includes('private UI value'), false)
33
+ } finally {
34
+ ;(process.stderr as any).write = previousWrite
35
+ if (previousFlag === undefined) delete process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS
36
+ else process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS = previousFlag
37
+ }
38
+ }
39
+
40
+ run().catch((error) => { console.error(error); process.exitCode = 1 })
@@ -1,10 +1,23 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
2
5
  import { runWithBudget, remainingBudget, markDispatchStarted } from '../../../src/utils/operation-budget.js'
3
6
  import { execCmd } from '../../../src/utils/exec.js'
4
7
  import { ToolsInteract } from '../../../src/interact/index.js'
5
8
  import { ToolsObserve } from '../../../src/observe/index.js'
6
9
 
7
10
  async function run() {
11
+ const markerDir = mkdtempSync(path.join(tmpdir(), 'mcp-exec-close-'))
12
+ try {
13
+ const marker = path.join(markerDir, 'closed')
14
+ const command = `process.on('SIGTERM', () => setTimeout(() => { require('fs').writeFileSync(${JSON.stringify(marker)}, 'closed'); process.exit(0) }, 50)); setInterval(() => {}, 1000)`
15
+ const response = await execCmd(process.execPath, ['-e', command], { timeout: 250 })
16
+ assert.equal(response.stderr, 'ACTION_TIMEOUT')
17
+ assert.equal(existsSync(marker), true, 'timeout must await child close before returning')
18
+ } finally {
19
+ rmSync(markerDir, { recursive: true, force: true })
20
+ }
8
21
  await runWithBudget(100, async budget => {
9
22
  assert.equal(budget.dispatchStarted, false)
10
23
  markDispatchStarted()
@@ -78,6 +78,36 @@ async function run() {
78
78
  assert.strictEqual(failedAssertion.success, false)
79
79
  assert.deepStrictEqual(failedAssertion.steps[0].result.verification, { mode: 'light', status: 'observed' })
80
80
  assert.strictEqual(postActionReads, 0)
81
+
82
+ ;(ToolsInteract as any).waitForUIHandler = originalWait
83
+ ;(ToolsInteract as any).expectElementVisibleHandler = originalExpectVisible
84
+ let dispatchedAt = 0
85
+ const readTimes: number[] = []
86
+ ;(ToolsObserve as any).getUITreeHandler = async () => {
87
+ readTimes.push(performance.now())
88
+ const target = readTimes.length === 1
89
+ return {
90
+ device: { platform: 'android', id: 'fixture' },
91
+ captured_at_ms: Date.now(),
92
+ elements: [{ contentDescription: target ? 'target' : 'result', bounds: [0, 0, 20, 20], visible: true, enabled: true, clickable: true }]
93
+ }
94
+ }
95
+ ;(ToolsInteract as any).tapElementHandler = async () => {
96
+ dispatchedAt = performance.now()
97
+ return { success: true, action_id: 'tap', action_type: 'tap_element' }
98
+ }
99
+ const freshResponse: any = await handleToolCall('run_journey', {
100
+ platform: 'android', responseMode: 'compact', defaults: { verificationMode: 'light' },
101
+ steps: [
102
+ { id: 'tap-target', type: 'tap', selector: { accessibility_id: 'target' } },
103
+ { id: 'assert-result', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'result' } } }
104
+ ]
105
+ })
106
+ const fresh = JSON.parse(freshResponse.content[0].text)
107
+ assert.strictEqual(fresh.success, true)
108
+ assert.strictEqual(readTimes.length, 3, 'target selection and two stability samples require separate reads')
109
+ assert.ok(readTimes[0] < dispatchedAt)
110
+ assert.ok(readTimes[1] >= dispatchedAt && readTimes[2] >= dispatchedAt, 'assertion reads must follow dispatch')
81
111
  } finally {
82
112
  _resetDeviceListersForTests()
83
113
  ;(ToolsInteract as any).waitForUIHandler = originalWait