mobile-debug-mcp 0.14.0 → 0.16.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/dist/android/interact.js +2 -2
  2. package/dist/android/observe.js +13 -0
  3. package/dist/cli/ios/run-ios-smoke.js +2 -2
  4. package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
  5. package/dist/interact/android.js +91 -0
  6. package/dist/interact/index.js +76 -0
  7. package/dist/interact/ios.js +120 -0
  8. package/dist/interact/shared/fingerprint.js +1 -0
  9. package/dist/interact/shared/scroll_to_element.js +1 -0
  10. package/dist/ios/interact.js +2 -2
  11. package/dist/ios/observe.js +12 -0
  12. package/dist/manage/android.js +162 -0
  13. package/dist/manage/index.js +364 -0
  14. package/dist/manage/ios.js +353 -0
  15. package/dist/observe/android.js +351 -0
  16. package/dist/observe/fingerprint.js +1 -0
  17. package/dist/observe/index.js +85 -0
  18. package/dist/observe/ios.js +320 -0
  19. package/dist/observe/test/device/logstream-real.js +34 -0
  20. package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
  21. package/dist/observe/test/device/run-scroll-test-android.js +22 -0
  22. package/dist/observe/test/device/test-ui-tree.js +67 -0
  23. package/dist/observe/test/device/wait_for_element_real.js +69 -0
  24. package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
  25. package/dist/observe/test/unit/logparse.test.js +39 -0
  26. package/dist/observe/test/unit/logstream.test.js +41 -0
  27. package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
  28. package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
  29. package/dist/server.js +41 -5
  30. package/dist/shared/fingerprint.js +72 -0
  31. package/dist/shared/scroll_to_element.js +98 -0
  32. package/dist/tools/interact.js +2 -2
  33. package/dist/tools/manage.js +2 -2
  34. package/dist/tools/observe.js +45 -43
  35. package/dist/utils/android/utils.js +373 -0
  36. package/dist/utils/cli/idb/check-idb.js +84 -0
  37. package/dist/utils/cli/idb/idb-helper.js +91 -0
  38. package/dist/utils/cli/idb/install-idb.js +82 -0
  39. package/dist/utils/cli/ios/preflight-ios.js +155 -0
  40. package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
  41. package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
  42. package/dist/utils/diagnostics.js +1 -1
  43. package/dist/utils/exec.js +34 -0
  44. package/dist/utils/ios/utils.js +301 -0
  45. package/dist/utils/resolve-device.js +2 -2
  46. package/dist/utils/ui/index.js +169 -0
  47. package/docs/CHANGELOG.md +8 -0
  48. package/docs/tools/interact.md +29 -0
  49. package/docs/tools/observe.md +24 -0
  50. package/package.json +1 -1
  51. package/src/{android/interact.ts → interact/android.ts} +3 -3
  52. package/src/{tools/interact.ts → interact/index.ts} +47 -3
  53. package/src/{ios/interact.ts → interact/ios.ts} +3 -3
  54. package/src/{android/manage.ts → manage/android.ts} +2 -2
  55. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  56. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  57. package/src/{android/observe.ts → observe/android.ts} +14 -26
  58. package/src/observe/index.ts +92 -0
  59. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  60. package/src/server.ts +45 -6
  61. package/src/types.ts +1 -0
  62. package/src/{android → utils/android}/utils.ts +12 -79
  63. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  64. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  65. package/src/utils/diagnostics.ts +1 -1
  66. package/src/utils/exec.ts +33 -0
  67. package/src/{ios → utils/ios}/utils.ts +2 -2
  68. package/src/utils/resolve-device.ts +2 -2
  69. package/src/{tools/scroll_to_element.ts → utils/ui/index.ts} +73 -2
  70. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  71. package/test/interact/unit/wait_for_screen_change.test.ts +32 -0
  72. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  73. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  74. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  76. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  77. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  78. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  79. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  80. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  81. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  82. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  83. package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
  84. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  85. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  86. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  87. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  88. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  89. package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
  90. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
  91. package/test/unit/index.ts +13 -11
  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,32 @@ 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
+ },
276
+ {
277
+ name: "wait_for_screen_change",
278
+ description: "Wait until the current screen fingerprint differs from a provided previousFingerprint. Useful to wait for navigation/animation completion.",
279
+ inputSchema: {
280
+ type: "object",
281
+ properties: {
282
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
283
+ previousFingerprint: { type: "string", description: "The fingerprint to compare against (required)" },
284
+ timeoutMs: { type: "number", description: "Timeout in ms to wait for change (default 5000)", default: 5000 },
285
+ pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300)", default: 300 },
286
+ deviceId: { type: "string", description: "Optional device id/udid to target" }
287
+ },
288
+ required: ["previousFingerprint"]
289
+ }
290
+ },
265
291
  {
266
292
  name: "wait_for_element",
267
293
  description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
@@ -540,6 +566,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
540
566
  const res = await ToolsObserve.getCurrentScreenHandler({ deviceId });
541
567
  return wrapResponse(res);
542
568
  }
569
+ if (name === "get_screen_fingerprint") {
570
+ const { platform, deviceId } = (args || {});
571
+ const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
572
+ return wrapResponse(res);
573
+ }
574
+ if (name === "wait_for_screen_change") {
575
+ const { platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId } = (args || {});
576
+ const res = await ToolsInteract.waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId });
577
+ return wrapResponse(res);
578
+ }
543
579
  if (name === "wait_for_element") {
544
580
  const { platform, text, timeout, deviceId } = (args || {});
545
581
  const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId });
@@ -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,6 +1,6 @@
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
5
  static async getInteractionService(platform, deviceId) {
6
6
  const effectivePlatform = platform || 'android';
@@ -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';
@@ -1,80 +1,82 @@
1
1
  import { resolveTargetDevice } from '../utils/resolve-device.js';
2
- import { AndroidObserve } from '../android/observe.js';
3
- import { iOSObserve } from '../ios/observe.js';
2
+ import { AndroidObserve, iOSObserve } from '../observe/index.js';
4
3
  export class ToolsObserve {
5
- static async getUITreeHandler({ platform, deviceId }) {
4
+ // Resolve a target device and return the appropriate observe instance and resolved info.
5
+ static async resolveObserve(platform, deviceId, appId) {
6
6
  if (platform === 'android') {
7
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
8
- return await new AndroidObserve().getUITree(resolved.id);
7
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId, appId });
8
+ return { observe: new AndroidObserve(), resolved };
9
9
  }
10
- else {
11
- const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
12
- return await new iOSObserve().getUITree(resolved.id);
10
+ if (platform === 'ios') {
11
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId, appId });
12
+ return { observe: new iOSObserve(), resolved };
13
+ }
14
+ // No platform specified: try android then ios
15
+ try {
16
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId, appId });
17
+ return { observe: new AndroidObserve(), resolved };
18
+ }
19
+ catch (e) {
20
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId, appId });
21
+ return { observe: new iOSObserve(), resolved };
13
22
  }
14
23
  }
24
+ static async getUITreeHandler({ platform, deviceId }) {
25
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
26
+ return await observe.getUITree(resolved.id);
27
+ }
15
28
  static async getCurrentScreenHandler({ deviceId }) {
16
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
17
- return await new AndroidObserve().getCurrentScreen(resolved.id);
29
+ const { observe, resolved } = await ToolsObserve.resolveObserve('android', deviceId);
30
+ // getCurrentScreen is Android-specific
31
+ return await observe.getCurrentScreen(resolved.id);
18
32
  }
19
33
  static async getLogsHandler({ platform, appId, deviceId, lines }) {
20
- if (platform === 'android') {
21
- const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
22
- const response = await new AndroidObserve().getLogs(appId, lines ?? 200, resolved.id);
34
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId);
35
+ if (observe instanceof AndroidObserve) {
36
+ const response = await observe.getLogs(appId, lines ?? 200, resolved.id);
23
37
  const logs = Array.isArray(response.logs) ? response.logs : [];
24
38
  const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'));
25
39
  return { device: response.device, logs, crashLines };
26
40
  }
27
41
  else {
28
- const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
29
- const resp = await new iOSObserve().getLogs(appId, resolved.id);
42
+ const resp = await observe.getLogs(appId, resolved.id);
30
43
  const logs = Array.isArray(resp.logs) ? resp.logs : [];
31
44
  const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'));
32
45
  return { device: resp.device, logs, crashLines };
33
46
  }
34
47
  }
35
48
  static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
36
- const effectivePlatform = platform || 'android';
37
49
  const sid = sessionId || 'default';
38
- if (effectivePlatform === 'android') {
39
- const resolved = await resolveTargetDevice({ platform: 'android', appId: packageName, deviceId });
40
- // Delegate to AndroidObserve's log stream methods
41
- return await new AndroidObserve().startLogStream(packageName, level || 'error', resolved.id, sid);
50
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, packageName);
51
+ if (observe instanceof AndroidObserve) {
52
+ return await observe.startLogStream(packageName, level || 'error', resolved.id, sid);
42
53
  }
43
54
  else {
44
- const resolved = await resolveTargetDevice({ platform: 'ios', appId: packageName, deviceId });
45
- // Delegate to iOSObserve for starting log streams
46
- return await new iOSObserve().startLogStream(packageName, resolved.id, sid);
55
+ return await observe.startLogStream(packageName, resolved.id, sid);
47
56
  }
48
57
  }
49
58
  static async readLogStreamHandler({ platform, sessionId, limit, since }) {
50
- const effectivePlatform = platform || 'android';
51
59
  const sid = sessionId || 'default';
52
- if (effectivePlatform === 'android') {
53
- return await new AndroidObserve().readLogStream(sid, limit ?? 100, since);
54
- }
55
- else {
56
- return await new iOSObserve().readLogStream(sid, limit ?? 100, since);
57
- }
60
+ const { observe } = await ToolsObserve.resolveObserve(platform);
61
+ return await observe.readLogStream(sid, limit ?? 100, since);
58
62
  }
59
63
  static async stopLogStreamHandler({ platform, sessionId }) {
60
- const effectivePlatform = platform || 'android';
61
64
  const sid = sessionId || 'default';
62
- if (effectivePlatform === 'android') {
63
- return await new AndroidObserve().stopLogStream(sid);
64
- }
65
- else {
66
- return await new iOSObserve().stopLogStream(sid);
67
- }
65
+ const { observe } = await ToolsObserve.resolveObserve(platform);
66
+ return await observe.stopLogStream(sid);
68
67
  }
69
68
  static async captureScreenshotHandler({ platform, deviceId }) {
70
- const effectivePlatform = platform || 'android';
71
- if (effectivePlatform === 'android') {
72
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
73
- return await new AndroidObserve().captureScreen(resolved.id);
69
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
70
+ if (observe instanceof AndroidObserve) {
71
+ return await observe.captureScreen(resolved.id);
74
72
  }
75
73
  else {
76
- const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
77
- return await new iOSObserve().captureScreenshot(resolved.id);
74
+ return await observe.captureScreenshot(resolved.id);
78
75
  }
79
76
  }
77
+ static async getScreenFingerprintHandler({ platform, deviceId } = {}) {
78
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
79
+ // Both observes implement getScreenFingerprint
80
+ return await observe.getScreenFingerprint(resolved.id);
81
+ }
80
82
  }