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
package/dist/interact/android.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js";
|
|
2
2
|
import { AndroidObserve } from "../observe/index.js";
|
|
3
|
-
import { scrollToElementShared } from "../
|
|
3
|
+
import { scrollToElementShared } from "../utils/ui/index.js";
|
|
4
4
|
export class AndroidInteract {
|
|
5
5
|
observe = new AndroidObserve();
|
|
6
6
|
async waitForElement(text, timeout, deviceId) {
|
package/dist/interact/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { AndroidInteract } from './android.js';
|
|
|
2
2
|
import { iOSInteract } from './ios.js';
|
|
3
3
|
export { AndroidInteract, iOSInteract };
|
|
4
4
|
import { resolveTargetDevice } from '../utils/resolve-device.js';
|
|
5
|
+
import { ToolsObserve } from '../observe/index.js';
|
|
5
6
|
export class ToolsInteract {
|
|
6
7
|
static async getInteractionService(platform, deviceId) {
|
|
7
8
|
const effectivePlatform = platform || 'android';
|
|
@@ -34,4 +35,42 @@ export class ToolsInteract {
|
|
|
34
35
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
35
36
|
return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id);
|
|
36
37
|
}
|
|
38
|
+
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }) {
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
let lastFingerprint = null;
|
|
41
|
+
while (Date.now() - start < timeoutMs) {
|
|
42
|
+
try {
|
|
43
|
+
const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
|
|
44
|
+
const fp = res?.fingerprint ?? null;
|
|
45
|
+
if (fp === null || fp === undefined) {
|
|
46
|
+
lastFingerprint = null;
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
lastFingerprint = fp;
|
|
51
|
+
if (fp !== previousFingerprint) {
|
|
52
|
+
// Stability confirmation
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
54
|
+
try {
|
|
55
|
+
const confirmRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
|
|
56
|
+
const confirmFp = confirmRes?.fingerprint ?? null;
|
|
57
|
+
if (confirmFp === fp) {
|
|
58
|
+
return { success: true, newFingerprint: fp, elapsedMs: Date.now() - start };
|
|
59
|
+
}
|
|
60
|
+
lastFingerprint = confirmFp;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// ignore and continue polling
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// ignore transient errors
|
|
71
|
+
}
|
|
72
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
73
|
+
}
|
|
74
|
+
return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start };
|
|
75
|
+
}
|
|
37
76
|
}
|
package/dist/interact/ios.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js";
|
|
3
3
|
import { iOSObserve } from "../observe/index.js";
|
|
4
|
-
import { scrollToElementShared } from "../
|
|
4
|
+
import { scrollToElementShared } from "../utils/ui/index.js";
|
|
5
5
|
export class iOSInteract {
|
|
6
6
|
observe = new iOSObserve();
|
|
7
7
|
async waitForElement(text, timeout, deviceId = "booted") {
|
|
@@ -1,72 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export { computeScreenFingerprint } from '../../utils/ui/index.js';
|
|
@@ -1,98 +1 @@
|
|
|
1
|
-
export
|
|
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
|
+
export { scrollToElementShared } from '../../utils/ui/index.js';
|
package/dist/observe/android.js
CHANGED
|
@@ -4,7 +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 "../
|
|
7
|
+
import { computeScreenFingerprint } from "../utils/ui/index.js";
|
|
8
8
|
const activeLogStreams = new Map();
|
|
9
9
|
export class AndroidObserve {
|
|
10
10
|
async getDeviceMetadata(appId, deviceId) {
|
package/dist/observe/index.js
CHANGED
|
@@ -82,4 +82,107 @@ export class ToolsObserve {
|
|
|
82
82
|
// Both observes implement getScreenFingerprint
|
|
83
83
|
return await observe.getScreenFingerprint(resolved.id);
|
|
84
84
|
}
|
|
85
|
+
static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
|
|
86
|
+
const timestamp = Date.now();
|
|
87
|
+
const out = { timestamp, reason: reason || '', activity: null, fingerprint: null, screenshot: null, ui_tree: null, logs: [] };
|
|
88
|
+
// Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
|
|
89
|
+
const sid = sessionId || 'default';
|
|
90
|
+
const tasks = {
|
|
91
|
+
screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
|
|
92
|
+
currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
93
|
+
fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
|
|
94
|
+
uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
|
|
95
|
+
readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
|
|
96
|
+
};
|
|
97
|
+
const results = await Promise.allSettled(Object.values(tasks));
|
|
98
|
+
const keys = Object.keys(tasks);
|
|
99
|
+
// Map results back to keys
|
|
100
|
+
for (let i = 0; i < results.length; i++) {
|
|
101
|
+
const key = keys[i];
|
|
102
|
+
const res = results[i];
|
|
103
|
+
if (res.status === 'fulfilled') {
|
|
104
|
+
const val = res.value;
|
|
105
|
+
if (key === 'screenshot') {
|
|
106
|
+
out.screenshot = val && val.screenshot ? val.screenshot : null;
|
|
107
|
+
}
|
|
108
|
+
else if (key === 'currentScreen') {
|
|
109
|
+
out.activity = val && ((val.activity || val.shortActivity)) ? (val.activity || val.shortActivity) : out.activity || '';
|
|
110
|
+
}
|
|
111
|
+
else if (key === 'fingerprint') {
|
|
112
|
+
if (val && val.fingerprint)
|
|
113
|
+
out.fingerprint = val.fingerprint;
|
|
114
|
+
if (val && val.activity)
|
|
115
|
+
out.activity = out.activity || val.activity;
|
|
116
|
+
if (val && val.error)
|
|
117
|
+
out.fingerprint_error = val.error;
|
|
118
|
+
}
|
|
119
|
+
else if (key === 'uiTree') {
|
|
120
|
+
out.ui_tree = val;
|
|
121
|
+
if (val && val.error)
|
|
122
|
+
out.ui_tree_error = val.error;
|
|
123
|
+
}
|
|
124
|
+
else if (key === 'readLogStream') {
|
|
125
|
+
// handle below after evaluating fallback
|
|
126
|
+
// temporarily attach to out._streamEntries
|
|
127
|
+
out._streamEntries = val && val.entries ? val.entries : [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const errMsg = res.reason instanceof Error ? res.reason.message : String(res.reason);
|
|
132
|
+
if (key === 'screenshot')
|
|
133
|
+
out.screenshot_error = errMsg;
|
|
134
|
+
if (key === 'currentScreen')
|
|
135
|
+
out.activity_error = errMsg;
|
|
136
|
+
if (key === 'fingerprint') {
|
|
137
|
+
out.fingerprint = null;
|
|
138
|
+
out.fingerprint_error = errMsg;
|
|
139
|
+
}
|
|
140
|
+
if (key === 'uiTree') {
|
|
141
|
+
out.ui_tree = null;
|
|
142
|
+
out.ui_tree_error = errMsg;
|
|
143
|
+
}
|
|
144
|
+
if (key === 'readLogStream') {
|
|
145
|
+
out._streamEntries = [];
|
|
146
|
+
out.logs_error = errMsg;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Logs: prefer stream entries, fallback to snapshot logs when empty
|
|
151
|
+
if (includeLogs) {
|
|
152
|
+
try {
|
|
153
|
+
let entries = Array.isArray(out._streamEntries) ? out._streamEntries : [];
|
|
154
|
+
if (!entries || entries.length === 0) {
|
|
155
|
+
const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines });
|
|
156
|
+
const raw = (gl && gl.logs) ? gl.logs : [];
|
|
157
|
+
entries = raw.slice(-Math.max(0, logLines)).map(line => {
|
|
158
|
+
const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(line) ? 'ERROR' : /\b(WARN| W )\b/i.test(line) ? 'WARN' : 'INFO';
|
|
159
|
+
return { timestamp: null, level, message: line };
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
entries = entries.map(ent => {
|
|
164
|
+
const msg = (ent && (ent.message || ent.msg)) ? (ent.message || ent.msg) : (typeof ent === 'string' ? ent : JSON.stringify(ent));
|
|
165
|
+
const levelRaw = (ent && (ent.level || ent.levelName || ent._level)) ? (ent.level || ent.levelName || ent._level) : '';
|
|
166
|
+
const level = (levelRaw && String(levelRaw)).toString().toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO');
|
|
167
|
+
let tsNum = null;
|
|
168
|
+
const maybeIso = ent && (ent._iso || ent.timestamp);
|
|
169
|
+
if (maybeIso && typeof maybeIso === 'string') {
|
|
170
|
+
const d = new Date(maybeIso);
|
|
171
|
+
if (!isNaN(d.getTime()))
|
|
172
|
+
tsNum = d.getTime();
|
|
173
|
+
}
|
|
174
|
+
return { timestamp: tsNum, level, message: msg };
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
out.logs = entries;
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
out.logs = [];
|
|
181
|
+
out.logs_error = e instanceof Error ? e.message : String(e);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Clean up internal temporary field
|
|
185
|
+
delete out._streamEntries;
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
85
188
|
}
|
package/dist/observe/ios.js
CHANGED
|
@@ -4,7 +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 '../utils/android/utils.js';
|
|
7
|
-
import { computeScreenFingerprint } from '../
|
|
7
|
+
import { computeScreenFingerprint } from '../utils/ui/index.js';
|
|
8
8
|
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
9
9
|
function parseIDBFrame(frame) {
|
|
10
10
|
if (!frame)
|
package/dist/server.js
CHANGED
|
@@ -195,6 +195,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
195
195
|
required: ["platform"]
|
|
196
196
|
}
|
|
197
197
|
},
|
|
198
|
+
{
|
|
199
|
+
name: "capture_debug_snapshot",
|
|
200
|
+
description: "Capture a complete debug snapshot (screenshot, ui tree, activity, fingerprint, logs). Returns structured JSON.",
|
|
201
|
+
inputSchema: {
|
|
202
|
+
type: "object",
|
|
203
|
+
properties: {
|
|
204
|
+
reason: { type: "string", description: "Optional reason for snapshot" },
|
|
205
|
+
includeLogs: { type: "boolean", description: "Whether to include logs", default: true },
|
|
206
|
+
logLines: { type: "number", description: "Maximum number of log lines to include", default: 200 },
|
|
207
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override" },
|
|
208
|
+
appId: { type: "string", description: "Optional appId to scope logs (package/bundle id)" },
|
|
209
|
+
deviceId: { type: "string", description: "Optional device serial/udid" },
|
|
210
|
+
sessionId: { type: "string", description: "Optional log stream session id to prefer" }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
},
|
|
198
214
|
{
|
|
199
215
|
name: "start_log_stream",
|
|
200
216
|
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.",
|
|
@@ -273,6 +289,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
273
289
|
}
|
|
274
290
|
}
|
|
275
291
|
},
|
|
292
|
+
{
|
|
293
|
+
name: "wait_for_screen_change",
|
|
294
|
+
description: "Wait until the current screen fingerprint differs from a provided previousFingerprint. Useful to wait for navigation/animation completion.",
|
|
295
|
+
inputSchema: {
|
|
296
|
+
type: "object",
|
|
297
|
+
properties: {
|
|
298
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
|
|
299
|
+
previousFingerprint: { type: "string", description: "The fingerprint to compare against (required)" },
|
|
300
|
+
timeoutMs: { type: "number", description: "Timeout in ms to wait for change (default 5000)", default: 5000 },
|
|
301
|
+
pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300)", default: 300 },
|
|
302
|
+
deviceId: { type: "string", description: "Optional device id/udid to target" }
|
|
303
|
+
},
|
|
304
|
+
required: ["previousFingerprint"]
|
|
305
|
+
}
|
|
306
|
+
},
|
|
276
307
|
{
|
|
277
308
|
name: "wait_for_element",
|
|
278
309
|
description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
|
|
@@ -541,6 +572,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
541
572
|
]
|
|
542
573
|
};
|
|
543
574
|
}
|
|
575
|
+
if (name === "capture_debug_snapshot") {
|
|
576
|
+
const { reason, includeLogs, logLines, platform, appId, deviceId, sessionId } = args;
|
|
577
|
+
const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, logLines, platform, appId, deviceId, sessionId });
|
|
578
|
+
return wrapResponse(res);
|
|
579
|
+
}
|
|
544
580
|
if (name === "get_ui_tree") {
|
|
545
581
|
const { platform, deviceId } = args;
|
|
546
582
|
const res = await ToolsObserve.getUITreeHandler({ platform, deviceId });
|
|
@@ -556,6 +592,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
556
592
|
const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
|
|
557
593
|
return wrapResponse(res);
|
|
558
594
|
}
|
|
595
|
+
if (name === "wait_for_screen_change") {
|
|
596
|
+
const { platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId } = (args || {});
|
|
597
|
+
const res = await ToolsInteract.waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId });
|
|
598
|
+
return wrapResponse(res);
|
|
599
|
+
}
|
|
559
600
|
if (name === "wait_for_element") {
|
|
560
601
|
const { platform, text, timeout, deviceId } = (args || {});
|
|
561
602
|
const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
1
|
import { promises as fsPromises, existsSync } from 'fs';
|
|
3
2
|
import path from 'path';
|
|
4
3
|
import { detectJavaHome } from '../java.js';
|
|
4
|
+
import { execCmd } from '../exec.js';
|
|
5
5
|
export function getAdbCmd() { return process.env.ADB_PATH || 'adb'; }
|
|
6
6
|
/**
|
|
7
7
|
* Prepare Gradle execution options for building an Android project.
|
|
@@ -76,76 +76,20 @@ function getAdbTimeout(args, customTimeout) {
|
|
|
76
76
|
return 20000;
|
|
77
77
|
return 120000;
|
|
78
78
|
}
|
|
79
|
-
export function execAdb(args, deviceId, options = {}) {
|
|
79
|
+
export async function execAdb(args, deviceId, options = {}) {
|
|
80
80
|
const adbArgs = getAdbArgs(args, deviceId);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
let stdout = '';
|
|
87
|
-
let stderr = '';
|
|
88
|
-
if (child.stdout) {
|
|
89
|
-
child.stdout.on('data', (data) => {
|
|
90
|
-
stdout += data.toString();
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
if (child.stderr) {
|
|
94
|
-
child.stderr.on('data', (data) => {
|
|
95
|
-
stderr += data.toString();
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
const timeoutMs = getAdbTimeout(args, customTimeout);
|
|
99
|
-
const timeout = setTimeout(() => {
|
|
100
|
-
child.kill();
|
|
101
|
-
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
|
|
102
|
-
}, timeoutMs);
|
|
103
|
-
child.on('close', (code) => {
|
|
104
|
-
clearTimeout(timeout);
|
|
105
|
-
if (code !== 0) {
|
|
106
|
-
// If there's an actual error (non-zero exit code), reject
|
|
107
|
-
reject(new Error(stderr.trim() || `Command failed with code ${code}`));
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
// If exit code is 0, resolve with stdout
|
|
111
|
-
resolve(stdout.trim());
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
child.on('error', (err) => {
|
|
115
|
-
clearTimeout(timeout);
|
|
116
|
-
reject(err);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
81
|
+
const timeoutMs = getAdbTimeout(args, options.timeout);
|
|
82
|
+
const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell });
|
|
83
|
+
if (res.exitCode !== 0)
|
|
84
|
+
throw new Error(res.stderr || `Command failed with code ${res.exitCode}`);
|
|
85
|
+
return res.stdout;
|
|
119
86
|
}
|
|
120
87
|
// Spawn adb but return full streams and exit code so callers can implement fallbacks or stream output
|
|
121
|
-
export function spawnAdb(args, deviceId, options = {}) {
|
|
88
|
+
export async function spawnAdb(args, deviceId, options = {}) {
|
|
122
89
|
const adbArgs = getAdbArgs(args, deviceId);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
let stdout = '';
|
|
127
|
-
let stderr = '';
|
|
128
|
-
if (child.stdout)
|
|
129
|
-
child.stdout.on('data', d => { stdout += d.toString(); });
|
|
130
|
-
if (child.stderr)
|
|
131
|
-
child.stderr.on('data', d => { stderr += d.toString(); });
|
|
132
|
-
const timeoutMs = getAdbTimeout(args, customTimeout);
|
|
133
|
-
const timeout = setTimeout(() => {
|
|
134
|
-
try {
|
|
135
|
-
child.kill();
|
|
136
|
-
}
|
|
137
|
-
catch { }
|
|
138
|
-
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
|
|
139
|
-
}, timeoutMs);
|
|
140
|
-
child.on('close', (code) => {
|
|
141
|
-
clearTimeout(timeout);
|
|
142
|
-
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code });
|
|
143
|
-
});
|
|
144
|
-
child.on('error', (err) => {
|
|
145
|
-
clearTimeout(timeout);
|
|
146
|
-
reject(err);
|
|
147
|
-
});
|
|
148
|
-
});
|
|
90
|
+
const timeoutMs = getAdbTimeout(args, options.timeout);
|
|
91
|
+
const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell });
|
|
92
|
+
return { stdout: res.stdout, stderr: res.stderr, code: res.exitCode };
|
|
149
93
|
}
|
|
150
94
|
export function getDeviceInfo(deviceId, metadata = {}) {
|
|
151
95
|
return {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
export async function execCmd(cmd, args, opts = {}) {
|
|
3
|
+
const { timeout = 0, env, cwd, shell } = opts;
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell });
|
|
6
|
+
let stdout = '';
|
|
7
|
+
let stderr = '';
|
|
8
|
+
if (child.stdout)
|
|
9
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
10
|
+
if (child.stderr)
|
|
11
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
12
|
+
let timedOut = false;
|
|
13
|
+
const timer = timeout && timeout > 0 ? setTimeout(() => {
|
|
14
|
+
timedOut = true;
|
|
15
|
+
try {
|
|
16
|
+
child.kill();
|
|
17
|
+
}
|
|
18
|
+
catch { }
|
|
19
|
+
resolve({ exitCode: null, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
20
|
+
}, timeout) : null;
|
|
21
|
+
child.on('close', (code) => {
|
|
22
|
+
if (timer)
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
if (timedOut)
|
|
25
|
+
return;
|
|
26
|
+
resolve({ exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
27
|
+
});
|
|
28
|
+
child.on('error', (err) => {
|
|
29
|
+
if (timer)
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
reject(err);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|