mobile-debug-mcp 0.15.0 → 0.17.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/interact/android.js +1 -1
- package/dist/interact/index.js +39 -0
- package/dist/interact/ios.js +1 -1
- package/dist/interact/shared/fingerprint.js +1 -72
- package/dist/interact/shared/scroll_to_element.js +1 -98
- package/dist/observe/android.js +1 -1
- package/dist/observe/index.js +103 -0
- package/dist/observe/ios.js +1 -1
- package/dist/server.js +41 -0
- package/dist/utils/android/utils.js +11 -67
- package/dist/utils/exec.js +34 -0
- package/dist/utils/ui/index.js +169 -0
- package/docs/CHANGELOG.md +7 -0
- package/docs/tools/interact.md +29 -0
- package/docs/tools/observe.md +44 -0
- package/package.json +1 -1
- package/src/interact/android.ts +1 -1
- package/src/interact/index.ts +45 -0
- package/src/interact/ios.ts +1 -1
- package/src/observe/android.ts +1 -1
- package/src/observe/index.ts +88 -0
- package/src/observe/ios.ts +1 -1
- package/src/server.ts +45 -0
- package/src/types.ts +1 -0
- package/src/utils/android/utils.ts +10 -77
- package/src/utils/exec.ts +33 -0
- package/src/{interact/shared/scroll_to_element.ts → utils/ui/index.ts} +73 -2
- package/test/interact/unit/wait_for_screen_change.test.ts +32 -0
- package/test/observe/unit/capture_debug_snapshot.test.ts +89 -0
- package/test/unit/index.ts +2 -0
- package/src/interact/shared/fingerprint.ts +0 -73
|
@@ -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,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.17.0]
|
|
6
|
+
- Added `capture_debug_snapshot` observe tool: captures a full debugging snapshot including screenshot (base64), UI tree, current activity (Android), screen fingerprint, and recent logs (prefers active log stream, falls back to snapshot logs). Returns a single structured JSON object and includes per-part error fields for partial failures. Implemented as `ToolsObserve.captureDebugSnapshotHandler` and registered in the server.
|
|
7
|
+
|
|
8
|
+
## [0.16.0]
|
|
9
|
+
- 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).
|
|
10
|
+
- Added unit tests covering immediate change, transient null fingerprints, stability confirmation and timeout behavior: `test/interact/unit/wait_for_screen_change.test.ts`.
|
|
11
|
+
|
|
5
12
|
## [0.15.0]
|
|
6
13
|
- 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}.
|
|
7
14
|
- Added computeScreenFingerprint utility used by observe/interact to normalise UI element significance across platforms (fingerprint shared between Android and iOS implementations).
|
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,50 @@ Response:
|
|
|
76
76
|
|
|
77
77
|
---
|
|
78
78
|
|
|
79
|
+
## capture_debug_snapshot
|
|
80
|
+
Capture a complete debug snapshot of the app state for diagnostics and post-mortem analysis.
|
|
81
|
+
|
|
82
|
+
Input:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"reason": "optional string describing why snapshot is taken",
|
|
87
|
+
"includeLogs": true,
|
|
88
|
+
"logLines": 200,
|
|
89
|
+
"platform": "android | ios",
|
|
90
|
+
"appId": "optional package/bundle id to scope logs",
|
|
91
|
+
"deviceId": "optional device serial/udid",
|
|
92
|
+
"sessionId": "optional log stream session id to prefer"
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Behavior:
|
|
97
|
+
- Captures screenshot (base64), current activity (Android), screen fingerprint, full UI tree, and recent logs.
|
|
98
|
+
- Prefers active log stream entries (read_log_stream) and falls back to get_logs when no active stream is available.
|
|
99
|
+
- Returns partial data when components fail and includes per-part error fields (e.g. `screenshot_error`, `ui_tree_error`).
|
|
100
|
+
- Caps logs to `logLines` entries and prefers recent entries.
|
|
101
|
+
- Fast by default: does not wait for new logs and avoids long blocking operations.
|
|
102
|
+
|
|
103
|
+
Response (example):
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"timestamp": 1710000000,
|
|
108
|
+
"reason": "Crash after tapping checkout",
|
|
109
|
+
"activity": "CheckoutActivity",
|
|
110
|
+
"fingerprint": "abc123",
|
|
111
|
+
"screenshot": "<base64 PNG string>",
|
|
112
|
+
"ui_tree": { ... },
|
|
113
|
+
"logs": [ { "timestamp": 1710000000, "level": "ERROR", "message": "NullPointerException at CheckoutViewModel" } ]
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Notes:
|
|
118
|
+
- Useful immediately after detecting crashes or unexpected UI behaviour.
|
|
119
|
+
- Do not expect perfect data during a crash; tool is designed to return best-effort context and include errors for failed parts.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
79
123
|
## get_screen_fingerprint
|
|
80
124
|
Generate a stable fingerprint representing the visible screen. Useful for detecting navigation changes, preventing loops, and synchronisation.
|
|
81
125
|
|
package/package.json
CHANGED
package/src/interact/android.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
2
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
|
|
3
3
|
import { AndroidObserve } from "../observe/index.js"
|
|
4
|
-
import { scrollToElementShared } from "../
|
|
4
|
+
import { scrollToElementShared } from "../utils/ui/index.js"
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
export class AndroidInteract {
|
package/src/interact/index.ts
CHANGED
|
@@ -3,6 +3,9 @@ import { iOSInteract } from './ios.js';
|
|
|
3
3
|
export { AndroidInteract, iOSInteract };
|
|
4
4
|
|
|
5
5
|
import { resolveTargetDevice } from '../utils/resolve-device.js'
|
|
6
|
+
import { ToolsObserve } from '../observe/index.js'
|
|
7
|
+
|
|
8
|
+
interface ScreenFingerprintResponse { fingerprint: string | null }
|
|
6
9
|
|
|
7
10
|
export class ToolsInteract {
|
|
8
11
|
|
|
@@ -44,4 +47,46 @@ export class ToolsInteract {
|
|
|
44
47
|
return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id)
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
|
|
51
|
+
const start = Date.now()
|
|
52
|
+
let lastFingerprint: string | null = null
|
|
53
|
+
|
|
54
|
+
while (Date.now() - start < timeoutMs) {
|
|
55
|
+
try {
|
|
56
|
+
const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
57
|
+
const fp = res?.fingerprint ?? null
|
|
58
|
+
if (fp === null || fp === undefined) {
|
|
59
|
+
lastFingerprint = null
|
|
60
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
lastFingerprint = fp
|
|
65
|
+
|
|
66
|
+
if (fp !== previousFingerprint) {
|
|
67
|
+
// Stability confirmation
|
|
68
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
|
|
69
|
+
try {
|
|
70
|
+
const confirmRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
71
|
+
const confirmFp = confirmRes?.fingerprint ?? null
|
|
72
|
+
if (confirmFp === fp) {
|
|
73
|
+
return { success: true, newFingerprint: fp, elapsedMs: Date.now() - start }
|
|
74
|
+
}
|
|
75
|
+
lastFingerprint = confirmFp
|
|
76
|
+
continue
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore and continue polling
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore transient errors
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
|
|
90
|
+
}
|
|
91
|
+
|
|
47
92
|
}
|
package/src/interact/ios.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from "child_process"
|
|
|
2
2
|
import { WaitForElementResponse, TapResponse, SwipeResponse } from "../types.js"
|
|
3
3
|
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
4
4
|
import { iOSObserve } from "../observe/index.js"
|
|
5
|
-
import { scrollToElementShared } from "../
|
|
5
|
+
import { scrollToElementShared } from "../utils/ui/index.js"
|
|
6
6
|
|
|
7
7
|
export class iOSInteract {
|
|
8
8
|
private observe = new iOSObserve();
|
package/src/observe/android.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, get
|
|
|
5
5
|
import { createWriteStream } from "fs"
|
|
6
6
|
import { promises as fsPromises } from "fs"
|
|
7
7
|
import path from "path"
|
|
8
|
-
import { computeScreenFingerprint } from "../
|
|
8
|
+
import { computeScreenFingerprint } from "../utils/ui/index.js"
|
|
9
9
|
|
|
10
10
|
const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
|
|
11
11
|
|
package/src/observe/index.ts
CHANGED
|
@@ -89,4 +89,92 @@ export class ToolsObserve {
|
|
|
89
89
|
// Both observes implement getScreenFingerprint
|
|
90
90
|
return await (observe as any).getScreenFingerprint(resolved.id)
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
|
|
94
|
+
const timestamp = Date.now()
|
|
95
|
+
const out: any = { timestamp, reason: reason || '', activity: null, fingerprint: null, screenshot: null, ui_tree: null, logs: [] }
|
|
96
|
+
|
|
97
|
+
// Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
|
|
98
|
+
const sid = sessionId || 'default'
|
|
99
|
+
const tasks = {
|
|
100
|
+
screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
|
|
101
|
+
currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
102
|
+
fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
|
|
103
|
+
uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
|
|
104
|
+
readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const results = await Promise.allSettled(Object.values(tasks))
|
|
108
|
+
const keys = Object.keys(tasks)
|
|
109
|
+
|
|
110
|
+
// Map results back to keys
|
|
111
|
+
for (let i = 0; i < results.length; i++) {
|
|
112
|
+
const key = keys[i]
|
|
113
|
+
const res = results[i] as PromiseSettledResult<any>
|
|
114
|
+
if (res.status === 'fulfilled') {
|
|
115
|
+
const val = res.value
|
|
116
|
+
if (key === 'screenshot') {
|
|
117
|
+
out.screenshot = val && val.screenshot ? val.screenshot : null
|
|
118
|
+
} else if (key === 'currentScreen') {
|
|
119
|
+
out.activity = val && ((val.activity || val.shortActivity)) ? (val.activity || val.shortActivity) : out.activity || ''
|
|
120
|
+
} else if (key === 'fingerprint') {
|
|
121
|
+
if (val && val.fingerprint) out.fingerprint = val.fingerprint
|
|
122
|
+
if (val && val.activity) out.activity = out.activity || val.activity
|
|
123
|
+
if (val && val.error) out.fingerprint_error = val.error
|
|
124
|
+
} else if (key === 'uiTree') {
|
|
125
|
+
out.ui_tree = val
|
|
126
|
+
if (val && val.error) out.ui_tree_error = val.error
|
|
127
|
+
} else if (key === 'readLogStream') {
|
|
128
|
+
// handle below after evaluating fallback
|
|
129
|
+
// temporarily attach to out._streamEntries
|
|
130
|
+
out._streamEntries = val && val.entries ? val.entries : []
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
const errMsg = res.reason instanceof Error ? res.reason.message : String(res.reason)
|
|
134
|
+
if (key === 'screenshot') out.screenshot_error = errMsg
|
|
135
|
+
if (key === 'currentScreen') out.activity_error = errMsg
|
|
136
|
+
if (key === 'fingerprint') { out.fingerprint = null; out.fingerprint_error = errMsg }
|
|
137
|
+
if (key === 'uiTree') { out.ui_tree = null; out.ui_tree_error = errMsg }
|
|
138
|
+
if (key === 'readLogStream') { out._streamEntries = [] ; out.logs_error = errMsg }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Logs: prefer stream entries, fallback to snapshot logs when empty
|
|
143
|
+
if (includeLogs) {
|
|
144
|
+
try {
|
|
145
|
+
let entries: any[] = Array.isArray(out._streamEntries) ? out._streamEntries : []
|
|
146
|
+
if (!entries || entries.length === 0) {
|
|
147
|
+
const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines })
|
|
148
|
+
const raw: string[] = (gl && (gl as any).logs) ? (gl as any).logs : []
|
|
149
|
+
entries = raw.slice(-Math.max(0, logLines)).map(line => {
|
|
150
|
+
const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(line) ? 'ERROR' : /\b(WARN| W )\b/i.test(line) ? 'WARN' : 'INFO'
|
|
151
|
+
return { timestamp: null, level, message: line }
|
|
152
|
+
})
|
|
153
|
+
} else {
|
|
154
|
+
entries = entries.map(ent => {
|
|
155
|
+
const msg = (ent && (ent.message || ent.msg)) ? (ent.message || ent.msg) : (typeof ent === 'string' ? ent : JSON.stringify(ent))
|
|
156
|
+
const levelRaw = (ent && (ent.level || ent.levelName || ent._level)) ? (ent.level || ent.levelName || ent._level) : ''
|
|
157
|
+
const level = (levelRaw && String(levelRaw)).toString().toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO')
|
|
158
|
+
let tsNum: number | null = null
|
|
159
|
+
const maybeIso = ent && ((ent._iso || ent.timestamp) as any)
|
|
160
|
+
if (maybeIso && typeof maybeIso === 'string') {
|
|
161
|
+
const d = new Date(maybeIso)
|
|
162
|
+
if (!isNaN(d.getTime())) tsNum = d.getTime()
|
|
163
|
+
}
|
|
164
|
+
return { timestamp: tsNum, level, message: msg }
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
out.logs = entries
|
|
169
|
+
} catch (e) {
|
|
170
|
+
out.logs = []
|
|
171
|
+
out.logs_error = e instanceof Error ? e.message : String(e)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Clean up internal temporary field
|
|
176
|
+
delete out._streamEntries
|
|
177
|
+
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
92
180
|
}
|
package/src/observe/ios.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcru
|
|
|
5
5
|
import { createWriteStream, promises as fsPromises } from 'fs'
|
|
6
6
|
import path from 'path'
|
|
7
7
|
import { parseLogLine } from '../utils/android/utils.js'
|
|
8
|
-
import { computeScreenFingerprint } from '../
|
|
8
|
+
import { computeScreenFingerprint } from '../utils/ui/index.js'
|
|
9
9
|
|
|
10
10
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
11
11
|
|
package/src/server.ts
CHANGED
|
@@ -215,6 +215,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
215
215
|
required: ["platform"]
|
|
216
216
|
}
|
|
217
217
|
},
|
|
218
|
+
{
|
|
219
|
+
name: "capture_debug_snapshot",
|
|
220
|
+
description: "Capture a complete debug snapshot (screenshot, ui tree, activity, fingerprint, logs). Returns structured JSON."
|
|
221
|
+
,
|
|
222
|
+
inputSchema: {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: {
|
|
225
|
+
reason: { type: "string", description: "Optional reason for snapshot" },
|
|
226
|
+
includeLogs: { type: "boolean", description: "Whether to include logs", default: true },
|
|
227
|
+
logLines: { type: "number", description: "Maximum number of log lines to include", default: 200 },
|
|
228
|
+
platform: { type: "string", enum: ["android","ios"], description: "Optional platform override" },
|
|
229
|
+
appId: { type: "string", description: "Optional appId to scope logs (package/bundle id)" },
|
|
230
|
+
deviceId: { type: "string", description: "Optional device serial/udid" },
|
|
231
|
+
sessionId: { type: "string", description: "Optional log stream session id to prefer" }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
218
235
|
{
|
|
219
236
|
name: "start_log_stream",
|
|
220
237
|
description: "Start streaming logs for a target application on Android or iOS. For Android this uses adb logcat --pid=<pid>; for iOS it streams `xcrun simctl spawn <device> log stream` with a predicate.",
|
|
@@ -294,6 +311,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
294
311
|
}
|
|
295
312
|
}
|
|
296
313
|
},
|
|
314
|
+
{
|
|
315
|
+
name: "wait_for_screen_change",
|
|
316
|
+
description: "Wait until the current screen fingerprint differs from a provided previousFingerprint. Useful to wait for navigation/animation completion.",
|
|
317
|
+
inputSchema: {
|
|
318
|
+
type: "object",
|
|
319
|
+
properties: {
|
|
320
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
|
|
321
|
+
previousFingerprint: { type: "string", description: "The fingerprint to compare against (required)" },
|
|
322
|
+
timeoutMs: { type: "number", description: "Timeout in ms to wait for change (default 5000)", default: 5000 },
|
|
323
|
+
pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300)", default: 300 },
|
|
324
|
+
deviceId: { type: "string", description: "Optional device id/udid to target" }
|
|
325
|
+
},
|
|
326
|
+
required: ["previousFingerprint"]
|
|
327
|
+
}
|
|
328
|
+
},
|
|
297
329
|
{
|
|
298
330
|
name: "wait_for_element",
|
|
299
331
|
description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
|
|
@@ -322,6 +354,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
322
354
|
required: ["platform", "text"]
|
|
323
355
|
}
|
|
324
356
|
},
|
|
357
|
+
|
|
325
358
|
{
|
|
326
359
|
name: "tap",
|
|
327
360
|
description: "Simulate a finger tap on the device screen at specific coordinates.",
|
|
@@ -578,6 +611,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
578
611
|
}
|
|
579
612
|
}
|
|
580
613
|
|
|
614
|
+
if (name === "capture_debug_snapshot") {
|
|
615
|
+
const { reason, includeLogs, logLines, platform, appId, deviceId, sessionId } = args as any
|
|
616
|
+
const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, logLines, platform, appId, deviceId, sessionId })
|
|
617
|
+
return wrapResponse(res)
|
|
618
|
+
}
|
|
619
|
+
|
|
581
620
|
if (name === "get_ui_tree") {
|
|
582
621
|
const { platform, deviceId } = args as any
|
|
583
622
|
const res = await ToolsObserve.getUITreeHandler({ platform, deviceId })
|
|
@@ -596,6 +635,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
596
635
|
return wrapResponse(res)
|
|
597
636
|
}
|
|
598
637
|
|
|
638
|
+
if (name === "wait_for_screen_change") {
|
|
639
|
+
const { platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId } = (args || {}) as any
|
|
640
|
+
const res = await ToolsInteract.waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId })
|
|
641
|
+
return wrapResponse(res)
|
|
642
|
+
}
|
|
643
|
+
|
|
599
644
|
if (name === "wait_for_element") {
|
|
600
645
|
const { platform, text, timeout, deviceId } = (args || {}) as any
|
|
601
646
|
const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId })
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { spawn } from 'child_process'
|
|
2
1
|
import { DeviceInfo, UIElement } from "../../types.js"
|
|
3
2
|
import { promises as fsPromises, existsSync } from 'fs'
|
|
4
3
|
import path from 'path'
|
|
5
4
|
import { detectJavaHome } from '../java.js'
|
|
5
|
+
import { execCmd } from '../exec.js'
|
|
6
6
|
|
|
7
7
|
export function getAdbCmd() { return process.env.ADB_PATH || 'adb' }
|
|
8
8
|
|
|
@@ -83,87 +83,20 @@ import type { SpawnOptions } from 'child_process'
|
|
|
83
83
|
|
|
84
84
|
export type SpawnOptionsWithTimeout = SpawnOptions & { timeout?: number }
|
|
85
85
|
|
|
86
|
-
export function execAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<string> {
|
|
86
|
+
export async function execAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<string> {
|
|
87
87
|
const adbArgs = getAdbArgs(args, deviceId)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
|
|
93
|
-
const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
|
|
94
|
-
|
|
95
|
-
let stdout = ''
|
|
96
|
-
let stderr = ''
|
|
97
|
-
|
|
98
|
-
if (child.stdout) {
|
|
99
|
-
child.stdout.on('data', (data) => {
|
|
100
|
-
stdout += data.toString()
|
|
101
|
-
})
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (child.stderr) {
|
|
105
|
-
child.stderr.on('data', (data) => {
|
|
106
|
-
stderr += data.toString()
|
|
107
|
-
})
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const timeoutMs = getAdbTimeout(args, customTimeout)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const timeout = setTimeout(() => {
|
|
114
|
-
child.kill()
|
|
115
|
-
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`))
|
|
116
|
-
}, timeoutMs)
|
|
117
|
-
|
|
118
|
-
child.on('close', (code) => {
|
|
119
|
-
clearTimeout(timeout)
|
|
120
|
-
if (code !== 0) {
|
|
121
|
-
// If there's an actual error (non-zero exit code), reject
|
|
122
|
-
reject(new Error(stderr.trim() || `Command failed with code ${code}`))
|
|
123
|
-
} else {
|
|
124
|
-
// If exit code is 0, resolve with stdout
|
|
125
|
-
resolve(stdout.trim())
|
|
126
|
-
}
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
child.on('error', (err) => {
|
|
130
|
-
clearTimeout(timeout)
|
|
131
|
-
reject(err)
|
|
132
|
-
})
|
|
133
|
-
})
|
|
88
|
+
const timeoutMs = getAdbTimeout(args, options.timeout)
|
|
89
|
+
const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env as any, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell })
|
|
90
|
+
if (res.exitCode !== 0) throw new Error(res.stderr || `Command failed with code ${res.exitCode}`)
|
|
91
|
+
return res.stdout
|
|
134
92
|
}
|
|
135
93
|
|
|
136
94
|
// Spawn adb but return full streams and exit code so callers can implement fallbacks or stream output
|
|
137
|
-
export function spawnAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<{ stdout: string, stderr: string, code: number | null }> {
|
|
95
|
+
export async function spawnAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<{ stdout: string, stderr: string, code: number | null }> {
|
|
138
96
|
const adbArgs = getAdbArgs(args, deviceId)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
let stdout = ''
|
|
144
|
-
let stderr = ''
|
|
145
|
-
|
|
146
|
-
if (child.stdout) child.stdout.on('data', d => { stdout += d.toString() })
|
|
147
|
-
if (child.stderr) child.stderr.on('data', d => { stderr += d.toString() })
|
|
148
|
-
|
|
149
|
-
const timeoutMs = getAdbTimeout(args, customTimeout)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const timeout = setTimeout(() => {
|
|
153
|
-
try { child.kill() } catch {}
|
|
154
|
-
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`))
|
|
155
|
-
}, timeoutMs)
|
|
156
|
-
|
|
157
|
-
child.on('close', (code) => {
|
|
158
|
-
clearTimeout(timeout)
|
|
159
|
-
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code })
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
child.on('error', (err) => {
|
|
163
|
-
clearTimeout(timeout)
|
|
164
|
-
reject(err)
|
|
165
|
-
})
|
|
166
|
-
})
|
|
97
|
+
const timeoutMs = getAdbTimeout(args, options.timeout)
|
|
98
|
+
const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env as any, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell })
|
|
99
|
+
return { stdout: res.stdout, stderr: res.stderr, code: res.exitCode }
|
|
167
100
|
}
|
|
168
101
|
|
|
169
102
|
export function getDeviceInfo(deviceId: string, metadata: Partial<DeviceInfo> = {}): DeviceInfo {
|