mobile-debug-mcp 0.14.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 (98) 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 +37 -0
  7. package/dist/interact/ios.js +120 -0
  8. package/dist/interact/shared/fingerprint.js +72 -0
  9. package/dist/interact/shared/scroll_to_element.js +98 -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 +21 -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 +429 -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/ios/utils.js +301 -0
  44. package/dist/utils/resolve-device.js +2 -2
  45. package/docs/CHANGELOG.md +4 -0
  46. package/docs/tools/observe.md +24 -0
  47. package/package.json +1 -1
  48. package/src/{android/interact.ts → interact/android.ts} +3 -3
  49. package/src/{tools/interact.ts → interact/index.ts} +4 -3
  50. package/src/{ios/interact.ts → interact/ios.ts} +3 -3
  51. package/src/interact/shared/fingerprint.ts +73 -0
  52. package/src/{tools → interact/shared}/scroll_to_element.ts +1 -1
  53. package/src/{android/manage.ts → manage/android.ts} +2 -2
  54. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  55. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  56. package/src/{android/observe.ts → observe/android.ts} +14 -26
  57. package/src/observe/index.ts +92 -0
  58. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  59. package/src/server.ts +23 -6
  60. package/src/{android → utils/android}/utils.ts +2 -2
  61. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  62. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  63. package/src/utils/diagnostics.ts +1 -1
  64. package/src/{ios → utils/ios}/utils.ts +2 -2
  65. package/src/utils/resolve-device.ts +2 -2
  66. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  67. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  68. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  69. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  70. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  71. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  72. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  73. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  74. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  76. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  77. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  78. package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
  79. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  80. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  81. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  82. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  83. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  84. package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
  85. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
  86. package/test/unit/index.ts +12 -11
  87. package/src/tools/observe.ts +0 -82
  88. package/test/device/README.md +0 -49
  89. package/test/device/index.ts +0 -27
  90. package/test/device/utils/test-dist.ts +0 -41
  91. package/test/unit/utils/detect-java.test.ts +0 -22
  92. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  93. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  94. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  95. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  96. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  97. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  98. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
2
- import { AndroidObserve } from "./observe.js";
3
- import { scrollToElementShared } from "../tools/scroll_to_element.js";
2
+ import { AndroidObserve } from "../observe/index.js";
3
+ import { scrollToElementShared } from "../intera../interact/shared/scroll_to_element.js";
4
4
  export class AndroidInteract {
5
5
  observe = new AndroidObserve();
6
6
  async waitForElement(text, timeout, deviceId) {
@@ -4,6 +4,7 @@ import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, get
4
4
  import { createWriteStream } from "fs";
5
5
  import { promises as fsPromises } from "fs";
6
6
  import path from "path";
7
+ import { computeScreenFingerprint } from "../intera../interact/shared/fingerprint.js";
7
8
  const activeLogStreams = new Map();
8
9
  export class AndroidObserve {
9
10
  async getDeviceMetadata(appId, deviceId) {
@@ -229,6 +230,18 @@ export class AndroidObserve {
229
230
  };
230
231
  }
231
232
  }
233
+ async getScreenFingerprint(deviceId) {
234
+ try {
235
+ const tree = await this.getUITree(deviceId);
236
+ if (!tree || tree.error)
237
+ return { fingerprint: null, error: tree.error };
238
+ const current = await this.getCurrentScreen(deviceId).catch(() => null);
239
+ return computeScreenFingerprint(tree, current, 'android', 50);
240
+ }
241
+ catch (e) {
242
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
243
+ }
244
+ }
232
245
  async startLogStream(packageName, level = 'error', deviceId, sessionId = 'default') {
233
246
  try {
234
247
  const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '');
@@ -1,5 +1,5 @@
1
- import { iOSObserve } from '../../ios/observe.js';
2
- import { iOSManage } from '../../ios/manage.js';
1
+ import { iOSObserve } from '../../observe/index.js';
2
+ import { iOSManage } from '../../manage/index.js';
3
3
  async function main() {
4
4
  const appId = process.argv[2] || 'com.apple.springboard';
5
5
  const deviceId = 'booted';
@@ -1,5 +1,5 @@
1
- import { iOSObserve } from '../../ios/observe.js';
2
- import { iOSInteract } from '../../ios/interact.js';
1
+ import { iOSObserve } from '../../observe/index.js';
2
+ import { iOSInteract } from '../../interact/index.js';
3
3
  async function main() {
4
4
  const deviceId = 'booted';
5
5
  const obs = new iOSObserve();
@@ -0,0 +1,91 @@
1
+ import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js";
2
+ import { AndroidObserve } from "../observe/index.js";
3
+ import { scrollToElementShared } from "../interact/shared/scroll_to_element.js";
4
+ export class AndroidInteract {
5
+ observe = new AndroidObserve();
6
+ async waitForElement(text, timeout, deviceId) {
7
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
8
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
9
+ const startTime = Date.now();
10
+ while (Date.now() - startTime < timeout) {
11
+ try {
12
+ const tree = await this.observe.getUITree(deviceId);
13
+ if (tree.error) {
14
+ return { device: deviceInfo, found: false, error: tree.error };
15
+ }
16
+ const element = tree.elements.find(e => e.text === text);
17
+ if (element) {
18
+ return { device: deviceInfo, found: true, element };
19
+ }
20
+ }
21
+ catch (e) {
22
+ // Ignore errors during polling and retry
23
+ console.error("Error polling UI tree:", e);
24
+ }
25
+ const elapsed = Date.now() - startTime;
26
+ const remaining = timeout - elapsed;
27
+ if (remaining <= 0)
28
+ break;
29
+ await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
30
+ }
31
+ return { device: deviceInfo, found: false };
32
+ }
33
+ async tap(x, y, deviceId) {
34
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
35
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
36
+ try {
37
+ await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId);
38
+ return { device: deviceInfo, success: true, x, y };
39
+ }
40
+ catch (e) {
41
+ return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
42
+ }
43
+ }
44
+ async swipe(x1, y1, x2, y2, duration, deviceId) {
45
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
46
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
47
+ try {
48
+ await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId);
49
+ return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration };
50
+ }
51
+ catch (e) {
52
+ return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) };
53
+ }
54
+ }
55
+ async typeText(text, deviceId) {
56
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
57
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
58
+ try {
59
+ // Encode spaces as %s to ensure proper input handling by adb shell input text
60
+ const encodedText = text.replace(/\s/g, '%s');
61
+ // Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
62
+ await execAdb(['shell', 'input', 'text', encodedText], deviceId);
63
+ return { device: deviceInfo, success: true, text };
64
+ }
65
+ catch (e) {
66
+ return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) };
67
+ }
68
+ }
69
+ async pressBack(deviceId) {
70
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
71
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
72
+ try {
73
+ await execAdb(['shell', 'input', 'keyevent', '4'], deviceId);
74
+ return { device: deviceInfo, success: true };
75
+ }
76
+ catch (e) {
77
+ return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) };
78
+ }
79
+ }
80
+ async scrollToElement(selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId) {
81
+ return await scrollToElementShared({
82
+ selector,
83
+ direction,
84
+ maxScrolls,
85
+ scrollAmount,
86
+ deviceId,
87
+ fetchTree: async () => await this.observe.getUITree(deviceId),
88
+ swipe: async (x1, y1, x2, y2, duration, devId) => await this.swipe(x1, y1, x2, y2, duration, devId)
89
+ });
90
+ }
91
+ }
@@ -0,0 +1,37 @@
1
+ import { AndroidInteract } from './android.js';
2
+ import { iOSInteract } from './ios.js';
3
+ export { AndroidInteract, iOSInteract };
4
+ import { resolveTargetDevice } from '../utils/resolve-device.js';
5
+ export class ToolsInteract {
6
+ static async getInteractionService(platform, deviceId) {
7
+ const effectivePlatform = platform || 'android';
8
+ const resolved = await resolveTargetDevice({ platform: effectivePlatform, deviceId });
9
+ const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract();
10
+ return { interact: interact, resolved, platform: effectivePlatform };
11
+ }
12
+ static async waitForElementHandler({ platform, text, timeout, deviceId }) {
13
+ const effectiveTimeout = timeout ?? 10000;
14
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
15
+ return await interact.waitForElement(text, effectiveTimeout, resolved.id);
16
+ }
17
+ static async tapHandler({ platform, x, y, deviceId }) {
18
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
19
+ return await interact.tap(x, y, resolved.id);
20
+ }
21
+ static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
22
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
23
+ return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
24
+ }
25
+ static async typeTextHandler({ text, deviceId }) {
26
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
27
+ return await new AndroidInteract().typeText(text, resolved.id);
28
+ }
29
+ static async pressBackHandler({ deviceId }) {
30
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
31
+ return await new AndroidInteract().pressBack(resolved.id);
32
+ }
33
+ static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }) {
34
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
35
+ return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id);
36
+ }
37
+ }
@@ -0,0 +1,120 @@
1
+ import { spawn } from "child_process";
2
+ import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js";
3
+ import { iOSObserve } from "../observe/index.js";
4
+ import { scrollToElementShared } from "../interact/shared/scroll_to_element.js";
5
+ export class iOSInteract {
6
+ observe = new iOSObserve();
7
+ async waitForElement(text, timeout, deviceId = "booted") {
8
+ const device = await getIOSDeviceMetadata(deviceId);
9
+ const startTime = Date.now();
10
+ while (Date.now() - startTime < timeout) {
11
+ try {
12
+ const tree = await this.observe.getUITree(deviceId);
13
+ if (tree.error) {
14
+ return { device, found: false, error: tree.error };
15
+ }
16
+ const element = tree.elements.find(e => e.text === text);
17
+ if (element) {
18
+ return { device, found: true, element };
19
+ }
20
+ }
21
+ catch (e) {
22
+ // Ignore errors during polling and retry
23
+ console.error("Error polling UI tree:", e);
24
+ }
25
+ const elapsed = Date.now() - startTime;
26
+ const remaining = timeout - elapsed;
27
+ if (remaining <= 0)
28
+ break;
29
+ await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
30
+ }
31
+ return { device, found: false };
32
+ }
33
+ async tap(x, y, deviceId = "booted") {
34
+ const device = await getIOSDeviceMetadata(deviceId);
35
+ // Use shared helper to detect idb
36
+ const idbExists = await isIDBInstalled();
37
+ if (!idbExists) {
38
+ return {
39
+ device,
40
+ success: false,
41
+ x,
42
+ y,
43
+ error: "iOS tap requires 'idb' (iOS Device Bridge)."
44
+ };
45
+ }
46
+ try {
47
+ const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
48
+ const args = ['ui', 'tap', x.toString(), y.toString()];
49
+ if (targetUdid) {
50
+ args.push('--udid', targetUdid);
51
+ }
52
+ await new Promise((resolve, reject) => {
53
+ const proc = spawn(getIdbCmd(), args);
54
+ let stderr = '';
55
+ proc.stderr.on('data', d => stderr += d.toString());
56
+ proc.on('close', code => {
57
+ if (code === 0)
58
+ resolve();
59
+ else
60
+ reject(new Error(`idb ui tap failed: ${stderr}`));
61
+ });
62
+ proc.on('error', err => reject(err));
63
+ });
64
+ return { device, success: true, x, y };
65
+ }
66
+ catch (e) {
67
+ return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
68
+ }
69
+ }
70
+ async swipe(x1, y1, x2, y2, duration, deviceId = "booted") {
71
+ const device = await getIOSDeviceMetadata(deviceId);
72
+ // Use shared helper to detect idb
73
+ const idbExists = await isIDBInstalled();
74
+ if (!idbExists) {
75
+ return {
76
+ device,
77
+ success: false,
78
+ start: [x1, y1],
79
+ end: [x2, y2],
80
+ duration,
81
+ error: "iOS swipe requires 'idb' (iOS Device Bridge)."
82
+ };
83
+ }
84
+ try {
85
+ const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
86
+ // idb 'ui swipe' does not accept a duration parameter; use coordinates only
87
+ const args = ['ui', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString()];
88
+ if (targetUdid) {
89
+ args.push('--udid', targetUdid);
90
+ }
91
+ await new Promise((resolve, reject) => {
92
+ const proc = spawn(getIdbCmd(), args);
93
+ let stderr = '';
94
+ proc.stderr.on('data', d => stderr += d.toString());
95
+ proc.on('close', code => {
96
+ if (code === 0)
97
+ resolve();
98
+ else
99
+ reject(new Error(`idb ui swipe failed: ${stderr}`));
100
+ });
101
+ proc.on('error', err => reject(err));
102
+ });
103
+ return { device, success: true, start: [x1, y1], end: [x2, y2], duration };
104
+ }
105
+ catch (e) {
106
+ return { device, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) };
107
+ }
108
+ }
109
+ async scrollToElement(selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId = 'booted') {
110
+ return await scrollToElementShared({
111
+ selector,
112
+ direction,
113
+ maxScrolls,
114
+ scrollAmount,
115
+ deviceId,
116
+ fetchTree: async () => await this.observe.getUITree(deviceId),
117
+ swipe: async (x1, y1, x2, y2, duration, devId) => await this.swipe(x1, y1, x2, y2, duration, devId)
118
+ });
119
+ }
120
+ }
@@ -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,7 +1,7 @@
1
1
  import { spawn } from "child_process";
2
2
  import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js";
3
- import { iOSObserve } from "./observe.js";
4
- import { scrollToElementShared } from "../tools/scroll_to_element.js";
3
+ import { iOSObserve } from "../observe/index.js";
4
+ import { scrollToElementShared } from "../intera../interact/shared/scroll_to_element.js";
5
5
  export class iOSInteract {
6
6
  observe = new iOSObserve();
7
7
  async waitForElement(text, timeout, deviceId = "booted") {
@@ -4,6 +4,7 @@ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcru
4
4
  import { createWriteStream, promises as fsPromises } from 'fs';
5
5
  import path from 'path';
6
6
  import { parseLogLine } from '../android/utils.js';
7
+ import { computeScreenFingerprint } from '../intera../interact/shared/fingerprint.js';
7
8
  // --- Helper Functions Specific to Observe ---
8
9
  const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
9
10
  function parseIDBFrame(frame) {
@@ -237,6 +238,17 @@ export class iOSObserve {
237
238
  };
238
239
  }
239
240
  }
241
+ async getScreenFingerprint(deviceId = 'booted') {
242
+ try {
243
+ const tree = await this.getUITree(deviceId);
244
+ if (!tree || tree.error)
245
+ return { fingerprint: null, error: tree && tree.error };
246
+ return computeScreenFingerprint(tree, null, 'ios', 50);
247
+ }
248
+ catch (e) {
249
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
250
+ }
251
+ }
240
252
  // --- Log stream methods ---
241
253
  async startLogStream(bundleId, deviceId = 'booted', sessionId = 'default') {
242
254
  try {