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.
- package/dist/android/interact.js +2 -2
- package/dist/android/observe.js +13 -0
- package/dist/cli/ios/run-ios-smoke.js +2 -2
- package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
- package/dist/interact/android.js +91 -0
- package/dist/interact/index.js +76 -0
- package/dist/interact/ios.js +120 -0
- package/dist/interact/shared/fingerprint.js +1 -0
- package/dist/interact/shared/scroll_to_element.js +1 -0
- package/dist/ios/interact.js +2 -2
- package/dist/ios/observe.js +12 -0
- package/dist/manage/android.js +162 -0
- package/dist/manage/index.js +364 -0
- package/dist/manage/ios.js +353 -0
- package/dist/observe/android.js +351 -0
- package/dist/observe/fingerprint.js +1 -0
- package/dist/observe/index.js +85 -0
- package/dist/observe/ios.js +320 -0
- package/dist/observe/test/device/logstream-real.js +34 -0
- package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
- package/dist/observe/test/device/run-scroll-test-android.js +22 -0
- package/dist/observe/test/device/test-ui-tree.js +67 -0
- package/dist/observe/test/device/wait_for_element_real.js +69 -0
- package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
- package/dist/observe/test/unit/logparse.test.js +39 -0
- package/dist/observe/test/unit/logstream.test.js +41 -0
- package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
- package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
- package/dist/server.js +41 -5
- package/dist/shared/fingerprint.js +72 -0
- package/dist/shared/scroll_to_element.js +98 -0
- package/dist/tools/interact.js +2 -2
- package/dist/tools/manage.js +2 -2
- package/dist/tools/observe.js +45 -43
- package/dist/utils/android/utils.js +373 -0
- package/dist/utils/cli/idb/check-idb.js +84 -0
- package/dist/utils/cli/idb/idb-helper.js +91 -0
- package/dist/utils/cli/idb/install-idb.js +82 -0
- package/dist/utils/cli/ios/preflight-ios.js +155 -0
- package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
- package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
- package/dist/utils/diagnostics.js +1 -1
- package/dist/utils/exec.js +34 -0
- package/dist/utils/ios/utils.js +301 -0
- package/dist/utils/resolve-device.js +2 -2
- package/dist/utils/ui/index.js +169 -0
- package/docs/CHANGELOG.md +8 -0
- package/docs/tools/interact.md +29 -0
- package/docs/tools/observe.md +24 -0
- package/package.json +1 -1
- package/src/{android/interact.ts → interact/android.ts} +3 -3
- package/src/{tools/interact.ts → interact/index.ts} +47 -3
- package/src/{ios/interact.ts → interact/ios.ts} +3 -3
- package/src/{android/manage.ts → manage/android.ts} +2 -2
- package/src/{tools/manage.ts → manage/index.ts} +7 -4
- package/src/{ios/manage.ts → manage/ios.ts} +1 -1
- package/src/{android/observe.ts → observe/android.ts} +14 -26
- package/src/observe/index.ts +92 -0
- package/src/{ios/observe.ts → observe/ios.ts} +17 -35
- package/src/server.ts +45 -6
- package/src/types.ts +1 -0
- package/src/{android → utils/android}/utils.ts +12 -79
- package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
- package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
- package/src/utils/diagnostics.ts +1 -1
- package/src/utils/exec.ts +33 -0
- package/src/{ios → utils/ios}/utils.ts +2 -2
- package/src/utils/resolve-device.ts +2 -2
- package/src/{tools/scroll_to_element.ts → utils/ui/index.ts} +73 -2
- package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
- package/test/interact/unit/wait_for_screen_change.test.ts +32 -0
- package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
- package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
- package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
- package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
- package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
- package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
- package/test/observe/device/run-screen-fingerprint.ts +36 -0
- package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
- package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
- package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
- package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
- package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
- package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
- package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
- package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
- package/test/unit/index.ts +13 -11
- package/src/tools/observe.ts +0 -82
- package/test/device/README.md +0 -49
- package/test/device/index.ts +0 -27
- package/test/device/utils/test-dist.ts +0 -41
- package/test/unit/utils/detect-java.test.ts +0 -22
- /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
- /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
- /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
- /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
- /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
- /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
- /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
}
|
|
73
|
+
export async function scrollToElementShared(opts) {
|
|
74
|
+
const { selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId, fetchTree, swipe, stabilizationDelayMs = 350 } = opts;
|
|
75
|
+
const matchElement = (el) => {
|
|
76
|
+
if (!el)
|
|
77
|
+
return false;
|
|
78
|
+
if (selector.text !== undefined && selector.text !== el.text)
|
|
79
|
+
return false;
|
|
80
|
+
if (selector.resourceId !== undefined && selector.resourceId !== el.resourceId)
|
|
81
|
+
return false;
|
|
82
|
+
if (selector.contentDesc !== undefined && selector.contentDesc !== el.contentDescription)
|
|
83
|
+
return false;
|
|
84
|
+
if (selector.className !== undefined && selector.className !== el.type)
|
|
85
|
+
return false;
|
|
86
|
+
return true;
|
|
87
|
+
};
|
|
88
|
+
const isVisible = (el, resolution) => {
|
|
89
|
+
if (!el)
|
|
90
|
+
return false;
|
|
91
|
+
if (el.visible === false)
|
|
92
|
+
return false;
|
|
93
|
+
if (!el.bounds || !resolution || !resolution.width || !resolution.height)
|
|
94
|
+
return (el.visible === undefined ? true : !!el.visible);
|
|
95
|
+
const [left, top, right, bottom] = el.bounds;
|
|
96
|
+
const withinY = bottom > 0 && top < resolution.height;
|
|
97
|
+
const withinX = right > 0 && left < resolution.width;
|
|
98
|
+
return withinX && withinY;
|
|
99
|
+
};
|
|
100
|
+
const findVisibleMatch = (elements, resolution) => {
|
|
101
|
+
if (!Array.isArray(elements))
|
|
102
|
+
return null;
|
|
103
|
+
for (const e of elements) {
|
|
104
|
+
if (matchElement(e) && isVisible(e, resolution))
|
|
105
|
+
return e;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
};
|
|
109
|
+
// Initial check
|
|
110
|
+
let tree = await fetchTree();
|
|
111
|
+
if (tree.error)
|
|
112
|
+
return { success: false, reason: tree.error, scrollsPerformed: 0 };
|
|
113
|
+
let found = findVisibleMatch(tree.elements, tree.resolution);
|
|
114
|
+
if (found) {
|
|
115
|
+
return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed: 0 };
|
|
116
|
+
}
|
|
117
|
+
const fingerprintOf = (t) => {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.stringify((t.elements || []).map((e) => ({ text: e.text, resourceId: e.resourceId, bounds: e.bounds })));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
let prevFingerprint = fingerprintOf(tree);
|
|
126
|
+
const width = (tree.resolution && tree.resolution.width) ? tree.resolution.width : 0;
|
|
127
|
+
const height = (tree.resolution && tree.resolution.height) ? tree.resolution.height : 0;
|
|
128
|
+
const centerX = Math.round(width / 2) || 50;
|
|
129
|
+
const clampPct = (v) => Math.max(0.05, Math.min(0.95, v));
|
|
130
|
+
const computeCoords = () => {
|
|
131
|
+
const defaultStart = direction === 'down' ? 0.8 : 0.2;
|
|
132
|
+
const startPct = clampPct(defaultStart);
|
|
133
|
+
const endPct = clampPct(defaultStart + (direction === 'down' ? -scrollAmount : scrollAmount));
|
|
134
|
+
const x1 = centerX;
|
|
135
|
+
const x2 = centerX;
|
|
136
|
+
const y1 = Math.round((height || 100) * startPct);
|
|
137
|
+
const y2 = Math.round((height || 100) * endPct);
|
|
138
|
+
return { x1, y1, x2, y2 };
|
|
139
|
+
};
|
|
140
|
+
const duration = 300;
|
|
141
|
+
let scrollsPerformed = 0;
|
|
142
|
+
for (let i = 0; i < maxScrolls; i++) {
|
|
143
|
+
const { x1, y1, x2, y2 } = computeCoords();
|
|
144
|
+
try {
|
|
145
|
+
await swipe(x1, y1, x2, y2, duration, deviceId);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
try {
|
|
149
|
+
console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
150
|
+
}
|
|
151
|
+
catch { }
|
|
152
|
+
}
|
|
153
|
+
scrollsPerformed++;
|
|
154
|
+
await new Promise(resolve => setTimeout(resolve, stabilizationDelayMs));
|
|
155
|
+
tree = await fetchTree();
|
|
156
|
+
if (tree.error)
|
|
157
|
+
return { success: false, reason: tree.error, scrollsPerformed: scrollsPerformed };
|
|
158
|
+
found = findVisibleMatch(tree.elements, tree.resolution);
|
|
159
|
+
if (found) {
|
|
160
|
+
return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed };
|
|
161
|
+
}
|
|
162
|
+
const fp = fingerprintOf(tree);
|
|
163
|
+
if (fp === prevFingerprint) {
|
|
164
|
+
return { success: false, reason: 'UI unchanged after scroll; likely end of list', scrollsPerformed: scrollsPerformed };
|
|
165
|
+
}
|
|
166
|
+
prevFingerprint = fp;
|
|
167
|
+
}
|
|
168
|
+
return { success: false, reason: 'Element not found after scrolling', scrollsPerformed: scrollsPerformed };
|
|
169
|
+
}
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.16.0]
|
|
6
|
+
- Added `wait_for_screen_change` interact tool: polls the platform-specific `get_screen_fingerprint` until it differs from a provided `previousFingerprint`, with configurable `timeoutMs` and `pollIntervalMs` and an optional stability confirmation poll to avoid reacting to transient UI flickers. Implemented at the interact layer and delegates fingerprinting to the observe implementations (Android/iOS).
|
|
7
|
+
- Added unit tests covering immediate change, transient null fingerprints, stability confirmation and timeout behavior: `test/interact/unit/wait_for_screen_change.test.ts`.
|
|
8
|
+
|
|
9
|
+
## [0.15.0]
|
|
10
|
+
- Reorganised repository for cohesion: merged tool handlers into feature entrypoints (src/observe, src/interact, src/manage) and moved platform helpers and CLI tooling into src/utils/{android,ios,cli}.
|
|
11
|
+
- Added computeScreenFingerprint utility used by observe/interact to normalise UI element significance across platforms (fingerprint shared between Android and iOS implementations).
|
|
12
|
+
|
|
5
13
|
## [0.14.0]
|
|
6
14
|
- Added `scroll_to_element` tool: platform-aware helper that scrolls until a UI element matching a selector is visible. Supports Android and iOS with configurable options: direction, maxScrolls, and scrollAmount. Includes unit tests and device runners under `test/device/` for manual E2E validation.
|
|
7
15
|
- Moved scroll logic into platform-specific implementations (`src/android/interact.ts`, `src/ios/interact.ts`) and delegated from `src/tools/interact.ts` to centralise platform behaviour.
|
package/docs/tools/interact.md
CHANGED
|
@@ -72,3 +72,32 @@ Notes:
|
|
|
72
72
|
- Android swipe uses `adb shell input swipe` with screen percentage coordinates. iOS swipe uses `idb ui swipe` command; note `idb` swipe does not accept a duration argument.
|
|
73
73
|
- Unit tests are located at `test/unit/observe/scroll_to_element.test.ts` and device runners at `test/device/observe/`.
|
|
74
74
|
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## wait_for_screen_change
|
|
78
|
+
|
|
79
|
+
Description:
|
|
80
|
+
- Waits until the current screen fingerprint differs from the provided `previousFingerprint`. Useful after taps, navigation, or other interactions that should change the visible UI.
|
|
81
|
+
|
|
82
|
+
Input example:
|
|
83
|
+
```
|
|
84
|
+
{ "platform": "android", "previousFingerprint": "<hex-fingerprint>", "timeoutMs": 5000, "pollIntervalMs": 300, "deviceId": "emulator-5554" }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Success response example:
|
|
88
|
+
```
|
|
89
|
+
{ "success": true, "newFingerprint": "<hex-fingerprint>", "elapsedMs": 420 }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Failure (timeout) example:
|
|
93
|
+
```
|
|
94
|
+
{ "success": false, "reason": "timeout", "lastFingerprint": "<hex-fingerprint>", "elapsedMs": 5000 }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Notes:
|
|
98
|
+
- Always compares to the original `previousFingerprint` (baseline is not updated during polling).
|
|
99
|
+
- Treats `null` fingerprints as transient; continues polling rather than returning success.
|
|
100
|
+
- Includes a stability confirmation: after detecting a different fingerprint it waits one additional poll interval and confirms the fingerprint is stable before returning success to avoid reacting to transient flickers or animation frames.
|
|
101
|
+
- Default `timeoutMs` is 5000ms and default `pollIntervalMs` is 300ms; callers may override these.
|
|
102
|
+
- Implemented as an interact-level tool and delegates platform-specific fingerprint calculation to the observe layer (`get_screen_fingerprint`).
|
|
103
|
+
|
package/docs/tools/observe.md
CHANGED
|
@@ -76,6 +76,30 @@ Response:
|
|
|
76
76
|
|
|
77
77
|
---
|
|
78
78
|
|
|
79
|
+
## get_screen_fingerprint
|
|
80
|
+
Generate a stable fingerprint representing the visible screen. Useful for detecting navigation changes, preventing loops, and synchronisation.
|
|
81
|
+
|
|
82
|
+
Input (optional):
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
{ "platform": "android", "deviceId": "emulator-5554" }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Response:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "fingerprint": "<sha256_hex>", "activity": "com.example.app.MainActivity" }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Notes:
|
|
95
|
+
- Uses get_ui_tree and (on Android) get_current_screen as inputs.
|
|
96
|
+
- Normalises visible, interactable or structurally significant elements (class/type, resourceId, text, contentDesc).
|
|
97
|
+
- Trims and lowercases text, filters out likely dynamic values (timestamps, counters).
|
|
98
|
+
- Sorts deterministically (top-to-bottom, left-to-right) and limits elements to 50.
|
|
99
|
+
- Returns fingerprint: null and an error message if the UI tree or activity cannot be retrieved.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
79
103
|
## start_log_stream / read_log_stream / stop_log_stream
|
|
80
104
|
Start a background adb logcat stream and retrieve parsed NDJSON entries.
|
|
81
105
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
|
-
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "
|
|
3
|
-
import { AndroidObserve } from "
|
|
4
|
-
import { scrollToElementShared } from "../
|
|
2
|
+
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
|
|
3
|
+
import { AndroidObserve } from "../observe/index.js"
|
|
4
|
+
import { scrollToElementShared } from "../utils/ui/index.js"
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
export class AndroidInteract {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { AndroidInteract } from './android.js';
|
|
2
|
+
import { iOSInteract } from './ios.js';
|
|
3
|
+
export { AndroidInteract, iOSInteract };
|
|
4
|
+
|
|
1
5
|
import { resolveTargetDevice } from '../utils/resolve-device.js'
|
|
2
|
-
import {
|
|
3
|
-
import { iOSInteract } from '../ios/interact.js'
|
|
6
|
+
import { ToolsObserve } from '../observe/index.js'
|
|
4
7
|
|
|
5
8
|
export class ToolsInteract {
|
|
6
9
|
|
|
@@ -42,5 +45,46 @@ export class ToolsInteract {
|
|
|
42
45
|
return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id)
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
}
|
|
48
|
+
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
|
|
49
|
+
const start = Date.now()
|
|
50
|
+
let lastFingerprint: string | null = null
|
|
51
|
+
|
|
52
|
+
while (Date.now() - start < timeoutMs) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId })
|
|
55
|
+
const fp = (res as any)?.fingerprint ?? null
|
|
56
|
+
if (fp === null || fp === undefined) {
|
|
57
|
+
lastFingerprint = null
|
|
58
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lastFingerprint = fp
|
|
63
|
+
|
|
64
|
+
if (fp !== previousFingerprint) {
|
|
65
|
+
// Stability confirmation
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
|
|
67
|
+
try {
|
|
68
|
+
const confirmRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId })
|
|
69
|
+
const confirmFp = (confirmRes as any)?.fingerprint ?? null
|
|
70
|
+
if (confirmFp === fp) {
|
|
71
|
+
return { success: true, newFingerprint: fp, elapsedMs: Date.now() - start }
|
|
72
|
+
}
|
|
73
|
+
lastFingerprint = confirmFp
|
|
74
|
+
continue
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore and continue polling
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore transient errors
|
|
82
|
+
}
|
|
46
83
|
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { WaitForElementResponse, TapResponse, SwipeResponse } from "../types.js"
|
|
3
|
-
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "
|
|
4
|
-
import { iOSObserve } from "
|
|
5
|
-
import { scrollToElementShared } from "../
|
|
3
|
+
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
4
|
+
import { iOSObserve } from "../observe/index.js"
|
|
5
|
+
import { scrollToElementShared } from "../utils/ui/index.js"
|
|
6
6
|
|
|
7
7
|
export class iOSInteract {
|
|
8
8
|
private observe = new iOSObserve();
|
|
@@ -2,7 +2,7 @@ import { promises as fs } from 'fs'
|
|
|
2
2
|
import { spawn } from 'child_process'
|
|
3
3
|
import path from 'path'
|
|
4
4
|
import { existsSync } from 'fs'
|
|
5
|
-
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from '
|
|
5
|
+
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from '../utils/android/utils.js'
|
|
6
6
|
import { execAdbWithDiagnostics } from '../utils/diagnostics.js'
|
|
7
7
|
import { detectJavaHome } from '../utils/java.js'
|
|
8
8
|
import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
|
|
@@ -12,7 +12,7 @@ export class AndroidManage {
|
|
|
12
12
|
void _variant
|
|
13
13
|
try {
|
|
14
14
|
// Always use the shared prepareGradle utility for consistent env/setup
|
|
15
|
-
const { execCmd, gradleArgs, spawnOpts } = await (await import('
|
|
15
|
+
const { execCmd, gradleArgs, spawnOpts } = await (await import('../utils/android/utils.js')).prepareGradle(projectPath)
|
|
16
16
|
await new Promise<void>((resolve, reject) => {
|
|
17
17
|
const proc = spawn(execCmd, gradleArgs, spawnOpts)
|
|
18
18
|
let stderr = ''
|
|
@@ -1,13 +1,16 @@
|
|
|
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 '
|
|
5
|
-
import { iOSManage } from '
|
|
6
|
-
import { findApk } from '../android/utils.js'
|
|
7
|
-
import { findAppBundle } from '../ios/utils.js'
|
|
4
|
+
import { AndroidManage } from './android.js'
|
|
5
|
+
import { iOSManage } from './ios.js'
|
|
6
|
+
import { findApk } from '../utils/android/utils.js'
|
|
7
|
+
import { findAppBundle } from '../utils/ios/utils.js'
|
|
8
8
|
import { execSync } from 'child_process'
|
|
9
9
|
import type { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
|
|
10
10
|
|
|
11
|
+
export { AndroidManage } from './android.js';
|
|
12
|
+
export { iOSManage } from './ios.js';
|
|
13
|
+
|
|
11
14
|
export async function detectProjectPlatform(projectPath: string): Promise<'ios'|'android'|'ambiguous'|'unknown'> {
|
|
12
15
|
// Recursively scan up to a limited depth for platform markers to avoid mis-detection
|
|
13
16
|
async function scan(dir: string, depth = 3): Promise<{ ios: boolean, android: boolean }>{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "fs"
|
|
2
2
|
import { spawn, spawnSync } from "child_process"
|
|
3
3
|
import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
|
|
4
|
-
import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "
|
|
4
|
+
import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "../utils/ios/utils.js"
|
|
5
5
|
import path from "path"
|
|
6
6
|
|
|
7
7
|
export class iOSManage {
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { XMLParser } from "fast-xml-parser"
|
|
3
3
|
import { GetLogsResponse, CaptureAndroidScreenResponse, GetUITreeResponse, GetCurrentScreenResponse, UIElement, DeviceInfo } from "../types.js"
|
|
4
|
-
import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "
|
|
4
|
+
import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "../utils/android/utils.js"
|
|
5
5
|
import { createWriteStream } from "fs"
|
|
6
6
|
import { promises as fsPromises } from "fs"
|
|
7
7
|
import path from "path"
|
|
8
|
+
import { computeScreenFingerprint } from "../utils/ui/index.js"
|
|
8
9
|
|
|
9
10
|
const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
12
|
export class AndroidObserve {
|
|
14
13
|
async getDeviceMetadata(appId: string, deviceId?: string): Promise<DeviceInfo> {
|
|
15
14
|
return getAndroidDeviceMetadata(appId, deviceId);
|
|
@@ -98,24 +97,16 @@ export class AndroidObserve {
|
|
|
98
97
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
99
98
|
|
|
100
99
|
try {
|
|
101
|
-
// We'll skip PID lookup for now to avoid potential hangs with 'pidof' on some emulators
|
|
102
|
-
// and rely on robust string matching against the log line.
|
|
103
|
-
|
|
104
|
-
// Get logs
|
|
105
100
|
const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId)
|
|
106
101
|
const allLogs = stdout.split('\n')
|
|
107
102
|
|
|
108
103
|
let filteredLogs = allLogs
|
|
109
104
|
if (appId) {
|
|
110
|
-
// Filter by checking if the line contains the appId string.
|
|
111
105
|
const matchingLogs = allLogs.filter(line => line.includes(appId))
|
|
112
106
|
|
|
113
107
|
if (matchingLogs.length > 0) {
|
|
114
108
|
filteredLogs = matchingLogs
|
|
115
109
|
} else {
|
|
116
|
-
// Fallback: if no logs match the appId, return the raw logs (last N lines)
|
|
117
|
-
// This matches the behavior of the "working" version provided by the user,
|
|
118
|
-
// ensuring they at least see system activity if the app is silent or crashing early.
|
|
119
110
|
filteredLogs = allLogs
|
|
120
111
|
}
|
|
121
112
|
}
|
|
@@ -132,10 +123,7 @@ export class AndroidObserve {
|
|
|
132
123
|
const deviceInfo: DeviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
133
124
|
|
|
134
125
|
return new Promise((resolve, reject) => {
|
|
135
|
-
// Need to construct ADB args manually since spawn handles it
|
|
136
126
|
const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
|
|
137
|
-
|
|
138
|
-
// Using spawn for screencap as well to ensure consistent process handling
|
|
139
127
|
const child = spawn(getAdbCmd(), args)
|
|
140
128
|
|
|
141
129
|
const chunks: Buffer[] = []
|
|
@@ -164,7 +152,6 @@ export class AndroidObserve {
|
|
|
164
152
|
const screenshotBuffer = Buffer.concat(chunks)
|
|
165
153
|
const screenshotBase64 = screenshotBuffer.toString('base64')
|
|
166
154
|
|
|
167
|
-
// Get resolution
|
|
168
155
|
execAdb(['shell', 'wm', 'size'], deviceId)
|
|
169
156
|
.then(sizeStdout => {
|
|
170
157
|
let width = 0
|
|
@@ -201,13 +188,8 @@ export class AndroidObserve {
|
|
|
201
188
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
202
189
|
|
|
203
190
|
try {
|
|
204
|
-
// Dumpsys activity can be slow on some devices, so we increase timeout to 10s
|
|
205
191
|
const output = await execAdb(['shell', 'dumpsys', 'activity', 'activities'], deviceId, { timeout: 10000 })
|
|
206
|
-
|
|
207
|
-
// Find the line with mResumedActivity or ResumedActivity (some versions might differ)
|
|
208
192
|
const lines = output.split('\n');
|
|
209
|
-
// Prioritize mResumedActivity, then ResumedActivity.
|
|
210
|
-
// Use strict regex match to ensure it starts with the key, avoiding false positives like 'mLastResumedActivity'.
|
|
211
193
|
let resumedLine = lines.find(line => /^\s*mResumedActivity:/.test(line));
|
|
212
194
|
|
|
213
195
|
if (!resumedLine) {
|
|
@@ -224,17 +206,12 @@ export class AndroidObserve {
|
|
|
224
206
|
}
|
|
225
207
|
}
|
|
226
208
|
|
|
227
|
-
// Regex to parse the line: ActivityRecord{... package/activity ...}
|
|
228
|
-
// Matches: ActivityRecord{<hex> <user> <package>/<activity> ...}
|
|
229
|
-
// We want to capture the component "package/activity" which is separated by space from other tokens.
|
|
230
|
-
// We use greedy match ([^ \{}]+) for activity to ensure we get the full name until a space or closing brace.
|
|
231
209
|
const match = resumedLine.match(/ActivityRecord\{[^ ]*(?:\s+[^ ]+)*\s+([^\/ ]+)\/([^ \{}]+)[^}]*\}/);
|
|
232
210
|
|
|
233
211
|
if (match) {
|
|
234
212
|
const packageName = match[1];
|
|
235
213
|
let activityName = match[2];
|
|
236
214
|
|
|
237
|
-
// Handle relative activity names (e.g. .LoginActivity)
|
|
238
215
|
if (activityName.startsWith('.')) {
|
|
239
216
|
activityName = packageName + activityName;
|
|
240
217
|
}
|
|
@@ -268,6 +245,18 @@ export class AndroidObserve {
|
|
|
268
245
|
}
|
|
269
246
|
}
|
|
270
247
|
|
|
248
|
+
async getScreenFingerprint(deviceId?: string): Promise<{ fingerprint: string | null; activity?: string; error?: string }> {
|
|
249
|
+
try {
|
|
250
|
+
const tree = await this.getUITree(deviceId)
|
|
251
|
+
if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
|
|
252
|
+
|
|
253
|
+
const current = await this.getCurrentScreen(deviceId).catch(() => null)
|
|
254
|
+
return computeScreenFingerprint(tree, current, 'android', 50)
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
271
260
|
async startLogStream(packageName: string, level: 'error' | 'warn' | 'info' | 'debug' = 'error', deviceId?: string, sessionId: string = 'default') {
|
|
272
261
|
try {
|
|
273
262
|
const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '')
|
|
@@ -328,7 +317,6 @@ export class AndroidObserve {
|
|
|
328
317
|
}
|
|
329
318
|
|
|
330
319
|
async readLogStream(sessionId: string = 'default', limit: number = 100, since?: string) {
|
|
331
|
-
// Prefer active stream if present, otherwise fall back to a well-known NDJSON file for the session
|
|
332
320
|
const entry = activeLogStreams.get(sessionId)
|
|
333
321
|
let file: string | undefined
|
|
334
322
|
if (entry && entry.file) file = entry.file
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { resolveTargetDevice } from '../utils/resolve-device.js'
|
|
2
|
+
import { AndroidObserve } from './android.js'
|
|
3
|
+
import { iOSObserve } from './ios.js'
|
|
4
|
+
|
|
5
|
+
export { AndroidObserve } from './android.js'
|
|
6
|
+
export { iOSObserve } from './ios.js'
|
|
7
|
+
|
|
8
|
+
export class ToolsObserve {
|
|
9
|
+
// Resolve a target device and return the appropriate observe instance and resolved info.
|
|
10
|
+
private static async resolveObserve(platform?: 'android' | 'ios', deviceId?: string, appId?: string) {
|
|
11
|
+
if (platform === 'android') {
|
|
12
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId, appId })
|
|
13
|
+
return { observe: new AndroidObserve(), resolved }
|
|
14
|
+
}
|
|
15
|
+
if (platform === 'ios') {
|
|
16
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId, appId })
|
|
17
|
+
return { observe: new iOSObserve(), resolved }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// No platform specified: try android then ios
|
|
21
|
+
try {
|
|
22
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId, appId })
|
|
23
|
+
return { observe: new AndroidObserve(), resolved }
|
|
24
|
+
} catch {
|
|
25
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId, appId })
|
|
26
|
+
return { observe: new iOSObserve(), resolved }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static async getUITreeHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
|
|
31
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
|
|
32
|
+
return await observe.getUITree(resolved.id)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static async getCurrentScreenHandler({ deviceId }: { deviceId?: string }) {
|
|
36
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve('android', deviceId)
|
|
37
|
+
// getCurrentScreen is Android-specific
|
|
38
|
+
return await (observe as AndroidObserve).getCurrentScreen(resolved.id)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static async getLogsHandler({ platform, appId, deviceId, lines }: { platform?: 'android' | 'ios', appId?: string, deviceId?: string, lines?: number }) {
|
|
42
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId)
|
|
43
|
+
if (observe instanceof AndroidObserve) {
|
|
44
|
+
const response = await observe.getLogs(appId, lines ?? 200, resolved.id)
|
|
45
|
+
const logs = Array.isArray(response.logs) ? response.logs : []
|
|
46
|
+
const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'))
|
|
47
|
+
return { device: response.device, logs, crashLines }
|
|
48
|
+
} else {
|
|
49
|
+
const resp = await (observe as iOSObserve).getLogs(appId, resolved.id)
|
|
50
|
+
const logs = Array.isArray(resp.logs) ? resp.logs : []
|
|
51
|
+
const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'))
|
|
52
|
+
return { device: resp.device, logs, crashLines }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }: { platform?: 'android' | 'ios', packageName: string, level?: 'error' | 'warn' | 'info' | 'debug', sessionId?: string, deviceId?: string }) {
|
|
57
|
+
const sid = sessionId || 'default'
|
|
58
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, packageName)
|
|
59
|
+
if (observe instanceof AndroidObserve) {
|
|
60
|
+
return await observe.startLogStream(packageName, level || 'error', resolved.id, sid)
|
|
61
|
+
} else {
|
|
62
|
+
return await (observe as iOSObserve).startLogStream(packageName, resolved.id, sid)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static async readLogStreamHandler({ platform, sessionId, limit, since }: { platform?: 'android' | 'ios', sessionId?: string, limit?: number, since?: string }) {
|
|
67
|
+
const sid = sessionId || 'default'
|
|
68
|
+
const { observe } = await ToolsObserve.resolveObserve(platform)
|
|
69
|
+
return await (observe as any).readLogStream(sid, limit ?? 100, since)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static async stopLogStreamHandler({ platform, sessionId }: { platform?: 'android' | 'ios', sessionId?: string }) {
|
|
73
|
+
const sid = sessionId || 'default'
|
|
74
|
+
const { observe } = await ToolsObserve.resolveObserve(platform)
|
|
75
|
+
return await (observe as any).stopLogStream(sid)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static async captureScreenshotHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
|
|
79
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
|
|
80
|
+
if (observe instanceof AndroidObserve) {
|
|
81
|
+
return await observe.captureScreen(resolved.id)
|
|
82
|
+
} else {
|
|
83
|
+
return await (observe as iOSObserve).captureScreenshot(resolved.id)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static async getScreenFingerprintHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string } = {}) {
|
|
88
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
|
|
89
|
+
// Both observes implement getScreenFingerprint
|
|
90
|
+
return await (observe as any).getScreenFingerprint(resolved.id)
|
|
91
|
+
}
|
|
92
|
+
}
|