mobile-debug-mcp 0.13.0 → 0.15.0

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 (103) hide show
  1. package/README.md +2 -2
  2. package/dist/android/interact.js +13 -1
  3. package/dist/android/observe.js +13 -0
  4. package/dist/cli/ios/run-ios-smoke.js +2 -2
  5. package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
  6. package/dist/interact/android.js +91 -0
  7. package/dist/interact/index.js +37 -0
  8. package/dist/interact/ios.js +120 -0
  9. package/dist/interact/shared/fingerprint.js +72 -0
  10. package/dist/interact/shared/scroll_to_element.js +98 -0
  11. package/dist/ios/interact.js +52 -1
  12. package/dist/ios/observe.js +12 -0
  13. package/dist/manage/android.js +162 -0
  14. package/dist/manage/index.js +364 -0
  15. package/dist/manage/ios.js +353 -0
  16. package/dist/observe/android.js +351 -0
  17. package/dist/observe/fingerprint.js +1 -0
  18. package/dist/observe/index.js +85 -0
  19. package/dist/observe/ios.js +320 -0
  20. package/dist/observe/test/device/logstream-real.js +34 -0
  21. package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
  22. package/dist/observe/test/device/run-scroll-test-android.js +22 -0
  23. package/dist/observe/test/device/test-ui-tree.js +67 -0
  24. package/dist/observe/test/device/wait_for_element_real.js +69 -0
  25. package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
  26. package/dist/observe/test/unit/logparse.test.js +39 -0
  27. package/dist/observe/test/unit/logstream.test.js +41 -0
  28. package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
  29. package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
  30. package/dist/server.js +54 -9
  31. package/dist/shared/fingerprint.js +72 -0
  32. package/dist/shared/scroll_to_element.js +98 -0
  33. package/dist/tools/interact.js +19 -22
  34. package/dist/tools/manage.js +2 -2
  35. package/dist/tools/observe.js +45 -43
  36. package/dist/tools/scroll_to_element.js +98 -0
  37. package/dist/utils/android/utils.js +429 -0
  38. package/dist/utils/cli/idb/check-idb.js +84 -0
  39. package/dist/utils/cli/idb/idb-helper.js +91 -0
  40. package/dist/utils/cli/idb/install-idb.js +82 -0
  41. package/dist/utils/cli/ios/preflight-ios.js +155 -0
  42. package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
  43. package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
  44. package/dist/utils/diagnostics.js +1 -1
  45. package/dist/utils/ios/utils.js +301 -0
  46. package/dist/utils/resolve-device.js +2 -2
  47. package/docs/CHANGELOG.md +11 -0
  48. package/docs/tools/TOOLS.md +3 -3
  49. package/docs/tools/interact.md +31 -0
  50. package/docs/tools/observe.md +24 -0
  51. package/package.json +1 -1
  52. package/src/{android/interact.ts → interact/android.ts} +15 -2
  53. package/src/interact/index.ts +47 -0
  54. package/src/{ios/interact.ts → interact/ios.ts} +58 -3
  55. package/src/interact/shared/fingerprint.ts +73 -0
  56. package/src/interact/shared/scroll_to_element.ts +110 -0
  57. package/src/{android/manage.ts → manage/android.ts} +2 -2
  58. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  59. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  60. package/src/{android/observe.ts → observe/android.ts} +14 -26
  61. package/src/observe/index.ts +92 -0
  62. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  63. package/src/server.ts +57 -10
  64. package/src/{android → utils/android}/utils.ts +2 -2
  65. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  66. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  67. package/src/utils/diagnostics.ts +1 -1
  68. package/src/{ios → utils/ios}/utils.ts +2 -2
  69. package/src/utils/resolve-device.ts +2 -2
  70. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  71. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  72. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  73. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  74. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  76. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  77. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  78. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  79. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  80. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  81. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  82. package/test/observe/device/run-scroll-test-android.ts +24 -0
  83. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  84. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  85. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  86. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  87. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  88. package/test/observe/unit/scroll_to_element.test.ts +129 -0
  89. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +3 -3
  90. package/test/unit/index.ts +12 -11
  91. package/src/tools/interact.ts +0 -45
  92. package/src/tools/observe.ts +0 -82
  93. package/test/device/README.md +0 -49
  94. package/test/device/index.ts +0 -27
  95. package/test/device/utils/test-dist.ts +0 -41
  96. package/test/unit/utils/detect-java.test.ts +0 -22
  97. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  98. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  99. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  100. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  101. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  102. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  103. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -0,0 +1,41 @@
1
+ import { promises as fs } from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { AndroidObserve } from '../../../../../index.js';
5
+ async function run() {
6
+ const tmp = os.tmpdir();
7
+ const sessionId = 'unit-test-logstream';
8
+ const file = path.join(tmp, `mobile-debug-log-${sessionId}.ndjson`);
9
+ // Prepare NDJSON with one crash entry and one info entry
10
+ const crashEntry = { timestamp: '2026-03-13T14:00:00.000Z', level: 'E', tag: 'AndroidRuntime', message: 'FATAL EXCEPTION: main\njava.lang.NullPointerException' };
11
+ const infoEntry = { timestamp: '2026-03-13T14:01:00.000Z', level: 'I', tag: 'MyTag', message: 'Info message' };
12
+ await fs.writeFile(file, JSON.stringify(crashEntry) + '\n' + JSON.stringify(infoEntry) + '\n');
13
+ try {
14
+ // Read all via AndroidObserve (falls back to session NDJSON file)
15
+ const obs = new AndroidObserve();
16
+ const { entries, crash_summary } = await obs.readLogStream(sessionId, 10);
17
+ if (!Array.isArray(entries) || entries.length !== 2)
18
+ throw new Error('Expected 2 entries');
19
+ if (!crash_summary || crash_summary.crash_detected !== true)
20
+ throw new Error('Expected crash_detected true');
21
+ if (!crash_summary.exception || !/NullPointerException/.test(crash_summary.exception))
22
+ throw new Error('Expected NullPointerException detected');
23
+ console.log('Test 1 PASS: basic parsing & crash detection');
24
+ // Test since filter (after first entry)
25
+ const since = new Date('2026-03-13T14:00:30.000Z').toISOString();
26
+ const r2 = await obs.readLogStream(sessionId, 10, since);
27
+ if (r2.entries.length !== 1)
28
+ throw new Error('Expected 1 entry after since filter');
29
+ console.log('Test 2 PASS: since filter');
30
+ // Test limit
31
+ const r3 = await obs.readLogStream(sessionId, 1);
32
+ if (r3.entries.length !== 1)
33
+ throw new Error('Expected 1 entry with limit=1');
34
+ console.log('Test 3 PASS: limit works');
35
+ console.log('ALL logstream tests passed');
36
+ }
37
+ finally {
38
+ await fs.unlink(file).catch(() => { });
39
+ }
40
+ }
41
+ run().catch(err => { console.error('Logstream tests failed:', err); process.exit(1); });
@@ -0,0 +1,113 @@
1
+ import { ToolsInteract } from '../../../src/tools/interact.js';
2
+ import { ToolsObserve } from '../../../src/tools/observe.js';
3
+ const origGet = ToolsObserve.getUITreeHandler;
4
+ const origSwipe = ToolsInteract.swipeHandler;
5
+ async function runTests() {
6
+ // Use a stable logger to avoid test harness replacing console.log between calls
7
+ console.log = (...args) => { try {
8
+ process.stdout.write(args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
9
+ }
10
+ catch { } };
11
+ console.log('Starting tests for scroll_to_element...');
12
+ // Test 1: Element found immediately
13
+ console.log('\nTest 1: Element found immediately')(ToolsObserve).getUITreeHandler = async () => ({
14
+ device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
15
+ screen: '',
16
+ resolution: { width: 1080, height: 1920 },
17
+ elements: [{
18
+ text: 'Target',
19
+ type: 'Button',
20
+ contentDescription: null,
21
+ clickable: true,
22
+ enabled: true,
23
+ visible: true,
24
+ bounds: [0, 0, 100, 100],
25
+ resourceId: null
26
+ }]
27
+ });
28
+ const res1 = await ToolsInteract.scrollToElementHandler({ platform: 'android', selector: { text: 'Target' }, direction: 'down', maxScrolls: 5, scrollAmount: 0.7 });
29
+ console.log('Result:', res1.success === true ? 'PASS' : 'FAIL');
30
+ console.log('scrollsPerformed:', res1.scrollsPerformed);
31
+ // Test 2: Element found after scrolling
32
+ console.log('\nTest 2: Element found after scrolling');
33
+ let calls = 0(ToolsObserve).getUITreeHandler = async () => {
34
+ calls++;
35
+ if (calls < 3) {
36
+ return {
37
+ device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
38
+ screen: '',
39
+ resolution: { width: 1080, height: 1920 },
40
+ elements: []
41
+ };
42
+ }
43
+ return {
44
+ device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
45
+ screen: '',
46
+ resolution: { width: 1080, height: 1920 },
47
+ elements: [{
48
+ text: 'Target',
49
+ type: 'Button',
50
+ contentDescription: null,
51
+ clickable: true,
52
+ enabled: true,
53
+ visible: true,
54
+ bounds: [0, 0, 100, 100],
55
+ resourceId: null
56
+ }]
57
+ };
58
+ };
59
+ // Stub swipe so it doesn't try to call adb/idb
60
+ ToolsInteract.swipeHandler = async () => ({ success: true });
61
+ const res2 = await ToolsInteract.scrollToElementHandler({ platform: 'android', selector: { text: 'Target' }, direction: 'down', maxScrolls: 5, scrollAmount: 0.7 });
62
+ console.log('Result:', res2.success === true ? 'PASS' : 'FAIL');
63
+ console.log('calls:', calls, calls >= 3 ? 'PASS' : 'FAIL');
64
+ // Test 3: UI unchanged stops early
65
+ console.log('\nTest 3: UI unchanged stops early')(ToolsObserve).getUITreeHandler = async () => ({
66
+ device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
67
+ screen: '',
68
+ resolution: { width: 1080, height: 1920 },
69
+ elements: []
70
+ })(ToolsInteract).swipeHandler = async () => ({ success: true });
71
+ const res3 = await ToolsInteract.scrollToElementHandler({ platform: 'android', selector: { text: 'Missing' }, direction: 'down', maxScrolls: 5, scrollAmount: 0.7 });
72
+ console.log('Result:', res3.success === false && res3.attempts === 1 ? 'PASS' : 'FAIL');
73
+ console.log('Reason:', res3.reason || JSON.stringify(res3));
74
+ // Test 4: Offscreen element scrolls into view
75
+ console.log('\nTest 4: Offscreen element scrolls into view');
76
+ const ai = new (await import('../../../src/android/interact.js')).AndroidInteract();
77
+ const origObserveGet = ai['observe'].getUITree;
78
+ const origAiSwipe = ai.swipe;
79
+ let swiped = false;
80
+ let swipeCalled = 0;
81
+ ai['observe'].getUITree = async () => {
82
+ if (!swiped) {
83
+ return {
84
+ device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
85
+ screen: '',
86
+ resolution: { width: 1080, height: 1920 },
87
+ elements: [{ text: null, type: 'android.view.View', resourceId: null, contentDescription: null, bounds: [0, 0, 1080, 200], visible: true }]
88
+ };
89
+ }
90
+ return {
91
+ device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
92
+ screen: '',
93
+ resolution: { width: 1080, height: 1920 },
94
+ elements: [{ text: 'OffscreenTarget', type: 'android.widget.Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [100, 400, 300, 460], resourceId: null }]
95
+ };
96
+ };
97
+ ai.swipe = async () => { swipeCalled++; swiped = true; return { success: true }; };
98
+ const r4 = await ai.scrollToElement({ text: 'OffscreenTarget' }, 'down', 3, 0.7, 'mock');
99
+ const ok4 = r4 && r4.success === true && r4.scrollsPerformed === 1 && swipeCalled === 1;
100
+ console.log('Result:', ok4 ? 'PASS' : 'FAIL');
101
+ console.log(' success:', r4.success, 'scrollsPerformed:', r4.scrollsPerformed, 'swipeCalled:', swipeCalled);
102
+ ai['observe'].getUITree = origObserveGet;
103
+ ai.swipe = origAiSwipe(ToolsObserve).getUITreeHandler = origGet;
104
+ ToolsInteract.swipeHandler = origSwipe;
105
+ }
106
+ // Ensure console.log is a function (some test runners replace it)
107
+ if (typeof console.log !== 'function') {
108
+ console.log = (...args) => { try {
109
+ process.stdout.write(args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
110
+ }
111
+ catch { /* swallow */ } };
112
+ }
113
+ runTests().catch(console.error);
@@ -0,0 +1,92 @@
1
+ import { AndroidInteract } from '../../../src/android/interact.js';
2
+ import { AndroidObserve } from '../../../../../index.js';
3
+ const originalGetUITree = AndroidObserve.prototype.getUITree;
4
+ async function runTests() {
5
+ console.log("Starting tests for wait_for_element...");
6
+ const interact = new AndroidInteract();
7
+ console.log("\nTest 1: Element found immediately");
8
+ AndroidObserve.prototype.getUITree = async () => ({
9
+ device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
10
+ screen: "",
11
+ resolution: { width: 1080, height: 1920 },
12
+ elements: [{
13
+ text: "Target",
14
+ type: "Button",
15
+ contentDescription: null,
16
+ clickable: true,
17
+ enabled: true,
18
+ visible: true,
19
+ bounds: [0, 0, 100, 100],
20
+ resourceId: null
21
+ }]
22
+ });
23
+ const start1 = Date.now();
24
+ const result1 = await interact.waitForElement("Target", 1000);
25
+ const elapsed1 = Date.now() - start1;
26
+ console.log("Result:", result1.found === true ? "PASS" : "FAIL");
27
+ console.log("Element:", result1.element ? "FOUND" : "MISSING");
28
+ console.log("Elapsed:", elapsed1, "ms");
29
+ console.log("\nTest 2: Element not found (timeout)");
30
+ AndroidObserve.prototype.getUITree = async () => ({
31
+ device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
32
+ screen: "",
33
+ resolution: { width: 1080, height: 1920 },
34
+ elements: []
35
+ });
36
+ const start2 = Date.now();
37
+ const result2 = await interact.waitForElement("Target", 1200);
38
+ const elapsed2 = Date.now() - start2;
39
+ console.log("Result:", result2.found === false ? "PASS" : "FAIL");
40
+ console.log("Elapsed time (should be >= 1200ms):", elapsed2, elapsed2 >= 1200 ? "PASS" : "FAIL");
41
+ console.log("\nTest 3: Element found after polling");
42
+ let calls = 0;
43
+ AndroidObserve.prototype.getUITree = async () => {
44
+ calls++;
45
+ if (calls < 3) {
46
+ return {
47
+ device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
48
+ screen: "",
49
+ resolution: { width: 1080, height: 1920 },
50
+ elements: []
51
+ };
52
+ }
53
+ return {
54
+ device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
55
+ screen: "",
56
+ resolution: { width: 1080, height: 1920 },
57
+ elements: [{
58
+ text: "Target",
59
+ type: "Button",
60
+ contentDescription: null,
61
+ clickable: true,
62
+ enabled: true,
63
+ visible: true,
64
+ bounds: [0, 0, 100, 100],
65
+ resourceId: null
66
+ }]
67
+ };
68
+ };
69
+ const start3 = Date.now();
70
+ const result3 = await interact.waitForElement("Target", 2000);
71
+ const elapsed3 = Date.now() - start3;
72
+ console.log("Result:", result3.found === true ? "PASS" : "FAIL");
73
+ console.log("Calls:", calls, calls >= 3 ? "PASS" : "FAIL");
74
+ console.log("Elapsed time (should be >= 1000ms):", elapsed3, elapsed3 >= 1000 ? "PASS" : "FAIL");
75
+ console.log("\nTest 4: Error handling (fast failure)");
76
+ AndroidObserve.prototype.getUITree = async () => ({
77
+ device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
78
+ screen: "",
79
+ resolution: { width: 0, height: 0 },
80
+ elements: [],
81
+ error: "ADB Connection Failed"
82
+ });
83
+ const start4 = Date.now();
84
+ const result4 = await interact.waitForElement("Target", 5000);
85
+ const elapsed4 = Date.now() - start4;
86
+ console.log("Result:", result4.found === false && result4.error === "ADB Connection Failed" ? "PASS" : "FAIL");
87
+ console.log("Error Message:", result4.error);
88
+ console.log("Elapsed time (should be < 1000ms):", elapsed4, elapsed4 < 1000 ? "PASS" : "FAIL");
89
+ // Restore
90
+ AndroidObserve.prototype.getUITree = originalGetUITree;
91
+ }
92
+ runTests().catch(console.error);
package/dist/server.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
- import { ToolsManage } from './tools/manage.js';
6
- import { ToolsInteract } from './tools/interact.js';
7
- import { ToolsObserve } from './tools/observe.js';
8
- import { AndroidManage } from './android/manage.js';
9
- import { iOSManage } from './ios/manage.js';
5
+ import { ToolsManage } from './manage/index.js';
6
+ import { ToolsInteract } from './interact/index.js';
7
+ import { ToolsObserve } from './observe/index.js';
8
+ import { AndroidManage } from './manage/index.js';
9
+ import { iOSManage } from './manage/index.js';
10
10
  const server = new Server({
11
11
  name: "mobile-debug-mcp",
12
12
  version: "0.7.0"
@@ -262,6 +262,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
262
262
  }
263
263
  }
264
264
  },
265
+ {
266
+ name: "get_screen_fingerprint",
267
+ description: "Generate a stable fingerprint representing the current visible screen (activity + visible UI elements).",
268
+ inputSchema: {
269
+ type: "object",
270
+ properties: {
271
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
272
+ deviceId: { type: "string", description: "Optional device id/udid to target" }
273
+ }
274
+ }
275
+ },
265
276
  {
266
277
  name: "wait_for_element",
267
278
  description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
@@ -325,8 +336,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
325
336
  properties: {
326
337
  platform: {
327
338
  type: "string",
328
- enum: ["android"],
329
- description: "Platform to swipe on (currently only android supported)"
339
+ enum: ["android", "ios"],
340
+ description: "Platform to swipe on (android or ios)"
330
341
  },
331
342
  x1: { type: "number", description: "Start X coordinate" },
332
343
  y1: { type: "number", description: "Start Y coordinate" },
@@ -341,6 +352,30 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
341
352
  required: ["x1", "y1", "x2", "y2", "duration"]
342
353
  }
343
354
  },
355
+ {
356
+ name: "scroll_to_element",
357
+ description: "Scroll the current screen until a target UI element becomes visible, then return its details.",
358
+ inputSchema: {
359
+ type: "object",
360
+ properties: {
361
+ platform: { type: "string", enum: ["android", "ios"], description: "Platform to operate on (required)" },
362
+ selector: {
363
+ type: "object",
364
+ properties: {
365
+ text: { type: "string" },
366
+ resourceId: { type: "string" },
367
+ contentDesc: { type: "string" },
368
+ className: { type: "string" }
369
+ }
370
+ },
371
+ direction: { type: "string", enum: ["down", "up"], default: "down" },
372
+ maxScrolls: { type: "number", default: 10 },
373
+ scrollAmount: { type: "number", default: 0.7 },
374
+ deviceId: { type: "string", description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected." }
375
+ },
376
+ required: ["platform", "selector"]
377
+ }
378
+ },
344
379
  {
345
380
  name: "type_text",
346
381
  description: "Type text into the currently focused input field on an Android device.",
@@ -516,6 +551,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
516
551
  const res = await ToolsObserve.getCurrentScreenHandler({ deviceId });
517
552
  return wrapResponse(res);
518
553
  }
554
+ if (name === "get_screen_fingerprint") {
555
+ const { platform, deviceId } = (args || {});
556
+ const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
557
+ return wrapResponse(res);
558
+ }
519
559
  if (name === "wait_for_element") {
520
560
  const { platform, text, timeout, deviceId } = (args || {});
521
561
  const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId });
@@ -527,8 +567,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
527
567
  return wrapResponse(res);
528
568
  }
529
569
  if (name === "swipe") {
530
- const { x1, y1, x2, y2, duration, deviceId } = (args || {});
531
- const res = await ToolsInteract.swipeHandler({ x1, y1, x2, y2, duration, deviceId });
570
+ const { platform = 'android', x1, y1, x2, y2, duration, deviceId } = (args || {});
571
+ const res = await ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId });
572
+ return wrapResponse(res);
573
+ }
574
+ if (name === "scroll_to_element") {
575
+ const { platform, selector, direction, maxScrolls, scrollAmount, deviceId } = (args || {});
576
+ const res = await ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId });
532
577
  return wrapResponse(res);
533
578
  }
534
579
  if (name === "type_text") {
@@ -0,0 +1,72 @@
1
+ import crypto from 'crypto';
2
+ const ANDROID_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewGroup', 'LinearLayout', 'FrameLayout', 'RelativeLayout', 'ScrollView', 'RecyclerView', 'TextView', 'ImageView'];
3
+ const IOS_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewController', 'UITableView', 'UICollectionView', 'UILabel', 'UIImageView', 'UIView', 'UIWindow', 'UIStackView', 'UITextView', 'UITableViewCell'];
4
+ function isDynamicText(t) {
5
+ if (!t)
6
+ return false;
7
+ const txt = t.trim();
8
+ if (!txt)
9
+ return false;
10
+ if (/\b\d{1,2}:\d{2}\b/.test(txt))
11
+ return true;
12
+ if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt))
13
+ return true;
14
+ if (/^\d+(?:\.\d+)?%$/.test(txt))
15
+ return true;
16
+ if (/^\d+$/.test(txt))
17
+ return true;
18
+ if (/^[\d,]{1,10}$/.test(txt))
19
+ return true;
20
+ return false;
21
+ }
22
+ function normalizeElement(e) {
23
+ return {
24
+ type: (e.type || '').toString(),
25
+ resourceId: (e.resourceId || '').toString(),
26
+ text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
27
+ contentDesc: (e.contentDescription || '').toString(),
28
+ bounds: Array.isArray(e.bounds) ? e.bounds.slice(0, 4).map((n) => Number(n) || 0) : [0, 0, 0, 0]
29
+ };
30
+ }
31
+ export function computeScreenFingerprint(tree, current, platform, limit = 50) {
32
+ try {
33
+ if (!tree || tree.error)
34
+ return { fingerprint: null, error: tree.error };
35
+ const activity = current && (current.activity || current.shortActivity) ? (current.activity || current.shortActivity) : '';
36
+ const candidates = (tree.elements || []).filter(e => {
37
+ if (!e)
38
+ return false;
39
+ if (!e.visible)
40
+ return false;
41
+ const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0;
42
+ const hasResource = !!e.resourceId;
43
+ const interactable = !!e.clickable || !!e.enabled;
44
+ const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES;
45
+ const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '');
46
+ return interactable || structurallySignificant;
47
+ });
48
+ const normalized = candidates.map(normalizeElement);
49
+ const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0));
50
+ filteredNormalized.sort((a, b) => {
51
+ const ay = (a.bounds && a.bounds[1]) || 0;
52
+ const by = (b.bounds && b.bounds[1]) || 0;
53
+ if (ay !== by)
54
+ return ay - by;
55
+ const ax = (a.bounds && a.bounds[0]) || 0;
56
+ const bx = (b.bounds && b.bounds[0]) || 0;
57
+ return ax - bx;
58
+ });
59
+ const limited = filteredNormalized.slice(0, Math.max(0, limit));
60
+ const payload = {
61
+ activity: platform === 'android' ? (activity || '') : '',
62
+ resolution: tree.resolution || { width: 0, height: 0 },
63
+ elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
64
+ };
65
+ const combined = JSON.stringify(payload);
66
+ const hash = crypto.createHash('sha256').update(combined).digest('hex');
67
+ return { fingerprint: hash, activity: activity };
68
+ }
69
+ catch (e) {
70
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
71
+ }
72
+ }
@@ -0,0 +1,98 @@
1
+ export async function scrollToElementShared(opts) {
2
+ const { selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId, fetchTree, swipe, stabilizationDelayMs = 350 } = opts;
3
+ const matchElement = (el) => {
4
+ if (!el)
5
+ return false;
6
+ if (selector.text !== undefined && selector.text !== el.text)
7
+ return false;
8
+ if (selector.resourceId !== undefined && selector.resourceId !== el.resourceId)
9
+ return false;
10
+ if (selector.contentDesc !== undefined && selector.contentDesc !== el.contentDescription)
11
+ return false;
12
+ if (selector.className !== undefined && selector.className !== el.type)
13
+ return false;
14
+ return true;
15
+ };
16
+ const isVisible = (el, resolution) => {
17
+ if (!el)
18
+ return false;
19
+ if (el.visible === false)
20
+ return false;
21
+ if (!el.bounds || !resolution || !resolution.width || !resolution.height)
22
+ return (el.visible === undefined ? true : !!el.visible);
23
+ const [left, top, right, bottom] = el.bounds;
24
+ const withinY = bottom > 0 && top < resolution.height;
25
+ const withinX = right > 0 && left < resolution.width;
26
+ return withinX && withinY;
27
+ };
28
+ const findVisibleMatch = (elements, resolution) => {
29
+ if (!Array.isArray(elements))
30
+ return null;
31
+ for (const e of elements) {
32
+ if (matchElement(e) && isVisible(e, resolution))
33
+ return e;
34
+ }
35
+ return null;
36
+ };
37
+ // Initial check
38
+ let tree = await fetchTree();
39
+ if (tree.error)
40
+ return { success: false, reason: tree.error, scrollsPerformed: 0 };
41
+ let found = findVisibleMatch(tree.elements, tree.resolution);
42
+ if (found) {
43
+ return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed: 0 };
44
+ }
45
+ const fingerprintOf = (t) => {
46
+ try {
47
+ return JSON.stringify((t.elements || []).map((e) => ({ text: e.text, resourceId: e.resourceId, bounds: e.bounds })));
48
+ }
49
+ catch {
50
+ return '';
51
+ }
52
+ };
53
+ let prevFingerprint = fingerprintOf(tree);
54
+ const width = (tree.resolution && tree.resolution.width) ? tree.resolution.width : 0;
55
+ const height = (tree.resolution && tree.resolution.height) ? tree.resolution.height : 0;
56
+ const centerX = Math.round(width / 2) || 50;
57
+ const clampPct = (v) => Math.max(0.05, Math.min(0.95, v));
58
+ const computeCoords = () => {
59
+ const defaultStart = direction === 'down' ? 0.8 : 0.2;
60
+ const startPct = clampPct(defaultStart);
61
+ const endPct = clampPct(defaultStart + (direction === 'down' ? -scrollAmount : scrollAmount));
62
+ const x1 = centerX;
63
+ const x2 = centerX;
64
+ const y1 = Math.round((height || 100) * startPct);
65
+ const y2 = Math.round((height || 100) * endPct);
66
+ return { x1, y1, x2, y2 };
67
+ };
68
+ const duration = 300;
69
+ let scrollsPerformed = 0;
70
+ for (let i = 0; i < maxScrolls; i++) {
71
+ const { x1, y1, x2, y2 } = computeCoords();
72
+ try {
73
+ await swipe(x1, y1, x2, y2, duration, deviceId);
74
+ }
75
+ catch (e) {
76
+ // Log swipe failures to aid debugging but don't fail the overall flow
77
+ try {
78
+ console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`);
79
+ }
80
+ catch { }
81
+ }
82
+ scrollsPerformed++;
83
+ await new Promise(resolve => setTimeout(resolve, stabilizationDelayMs));
84
+ tree = await fetchTree();
85
+ if (tree.error)
86
+ return { success: false, reason: tree.error, scrollsPerformed: scrollsPerformed };
87
+ found = findVisibleMatch(tree.elements, tree.resolution);
88
+ if (found) {
89
+ return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed };
90
+ }
91
+ const fp = fingerprintOf(tree);
92
+ if (fp === prevFingerprint) {
93
+ return { success: false, reason: 'UI unchanged after scroll; likely end of list', scrollsPerformed: scrollsPerformed };
94
+ }
95
+ prevFingerprint = fp;
96
+ }
97
+ return { success: false, reason: 'Element not found after scrolling', scrollsPerformed: scrollsPerformed };
98
+ }
@@ -1,32 +1,25 @@
1
1
  import { resolveTargetDevice } from '../utils/resolve-device.js';
2
- import { AndroidInteract } from '../android/interact.js';
3
- import { iOSInteract } from '../ios/interact.js';
2
+ import { AndroidInteract } from '../interact/index.js';
3
+ import { iOSInteract } from '../interact/index.js';
4
4
  export class ToolsInteract {
5
+ static async getInteractionService(platform, deviceId) {
6
+ const effectivePlatform = platform || 'android';
7
+ const resolved = await resolveTargetDevice({ platform: effectivePlatform, deviceId });
8
+ const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract();
9
+ return { interact: interact, resolved, platform: effectivePlatform };
10
+ }
5
11
  static async waitForElementHandler({ platform, text, timeout, deviceId }) {
6
12
  const effectiveTimeout = timeout ?? 10000;
7
- if (platform === 'android') {
8
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
9
- return await new AndroidInteract().waitForElement(text, effectiveTimeout, resolved.id);
10
- }
11
- else {
12
- const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
13
- return await new iOSInteract().waitForElement(text, effectiveTimeout, resolved.id);
14
- }
13
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
14
+ return await interact.waitForElement(text, effectiveTimeout, resolved.id);
15
15
  }
16
16
  static async tapHandler({ platform, x, y, deviceId }) {
17
- const effectivePlatform = platform || 'android';
18
- if (effectivePlatform === 'android') {
19
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
20
- return await new AndroidInteract().tap(x, y, resolved.id);
21
- }
22
- else {
23
- const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
24
- return await new iOSInteract().tap(x, y, resolved.id);
25
- }
17
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
18
+ return await interact.tap(x, y, resolved.id);
26
19
  }
27
- static async swipeHandler({ x1, y1, x2, y2, duration, deviceId }) {
28
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
29
- return await new AndroidInteract().swipe(x1, y1, x2, y2, duration, resolved.id);
20
+ static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
21
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
22
+ return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
30
23
  }
31
24
  static async typeTextHandler({ text, deviceId }) {
32
25
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
@@ -36,4 +29,8 @@ export class ToolsInteract {
36
29
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
37
30
  return await new AndroidInteract().pressBack(resolved.id);
38
31
  }
32
+ static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }) {
33
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
34
+ return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id);
35
+ }
39
36
  }
@@ -1,8 +1,8 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
3
  import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js';
4
- import { AndroidManage } from '../android/manage.js';
5
- import { iOSManage } from '../ios/manage.js';
4
+ import { AndroidManage } from '../manage/index.js';
5
+ import { iOSManage } from '../manage/index.js';
6
6
  import { findApk } from '../android/utils.js';
7
7
  import { findAppBundle } from '../ios/utils.js';
8
8
  import { execSync } from 'child_process';