mobile-debug-mcp 0.21.3 → 0.21.5
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/index.js +193 -4
- package/dist/observe/android.js +4 -2
- package/dist/observe/index.js +4 -4
- package/dist/observe/ios.js +38 -9
- package/dist/server.js +18 -11
- package/docs/CHANGELOG.md +7 -0
- package/docs/tools/observe.md +13 -3
- package/package.json +1 -1
- package/src/interact/index.ts +189 -4
- package/src/observe/android.ts +4 -2
- package/src/observe/index.ts +4 -4
- package/src/observe/ios.ts +35 -8
- package/src/server.ts +19 -11
- package/src/types.ts +4 -0
- package/test/interact/unit/wait_for_ui_contract.test.ts +33 -0
- package/test/interact/unit/wait_for_ui_new.test.ts +57 -0
- package/test/observe/unit/ios-getlogs.test.ts +67 -0
- package/test/helpers/.gitkeep +0 -0
- package/test/manage/device/run-install-kmp.ts +0 -18
package/dist/interact/index.js
CHANGED
|
@@ -4,6 +4,16 @@ export { AndroidInteract, iOSInteract };
|
|
|
4
4
|
import { resolveTargetDevice } from '../utils/resolve-device.js';
|
|
5
5
|
import { ToolsObserve } from '../observe/index.js';
|
|
6
6
|
export class ToolsInteract {
|
|
7
|
+
static _normalize(s) {
|
|
8
|
+
if (s === null || s === undefined)
|
|
9
|
+
return '';
|
|
10
|
+
try {
|
|
11
|
+
return String(s).toLowerCase().trim();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return '';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
7
17
|
static async getInteractionService(platform, deviceId) {
|
|
8
18
|
const effectivePlatform = platform || 'android';
|
|
9
19
|
const resolved = await resolveTargetDevice({ platform: effectivePlatform, deviceId });
|
|
@@ -34,7 +44,7 @@ export class ToolsInteract {
|
|
|
34
44
|
// Try to use observe layer to fetch the current UI tree and perform a fast semantic search
|
|
35
45
|
const start = Date.now();
|
|
36
46
|
const deadline = start + timeoutMs;
|
|
37
|
-
const normalize =
|
|
47
|
+
const normalize = ToolsInteract._normalize;
|
|
38
48
|
const q = normalize(query);
|
|
39
49
|
if (!q)
|
|
40
50
|
return { found: false, error: 'Empty query' };
|
|
@@ -215,9 +225,188 @@ export class ToolsInteract {
|
|
|
215
225
|
const scoreVal = Math.min(1, Number(bestScore.toFixed(3)));
|
|
216
226
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal };
|
|
217
227
|
}
|
|
218
|
-
static async waitForUIHandler({
|
|
219
|
-
|
|
220
|
-
|
|
228
|
+
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }) {
|
|
229
|
+
const overallStart = Date.now();
|
|
230
|
+
// Validate selector: require at least one non-empty field (text, resource_id, or accessibility_id)
|
|
231
|
+
const hasText = typeof selector?.text === 'string' && selector.text.trim().length > 0;
|
|
232
|
+
const hasResId = typeof selector?.resource_id === 'string' && selector.resource_id.trim().length > 0;
|
|
233
|
+
const hasAccId = typeof selector?.accessibility_id === 'string' && selector.accessibility_id.trim().length > 0;
|
|
234
|
+
if (!hasText && !hasResId && !hasAccId) {
|
|
235
|
+
return {
|
|
236
|
+
status: 'timeout',
|
|
237
|
+
error: {
|
|
238
|
+
code: 'INVALID_SELECTOR',
|
|
239
|
+
message: 'Selector must include at least one non-empty field: text, resource_id, or accessibility_id'
|
|
240
|
+
},
|
|
241
|
+
metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Validate condition
|
|
245
|
+
if (!['exists', 'not_exists', 'visible', 'clickable'].includes(condition)) {
|
|
246
|
+
return { status: 'timeout', error: { code: 'INVALID_CONDITION', message: `Unsupported condition: ${condition}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
|
|
247
|
+
}
|
|
248
|
+
// Platform check
|
|
249
|
+
if (platform && !['android', 'ios'].includes(platform)) {
|
|
250
|
+
return { status: 'timeout', error: { code: 'PLATFORM_NOT_SUPPORTED', message: `Unsupported platform: ${platform}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
|
|
251
|
+
}
|
|
252
|
+
const effectivePoll = Math.max(50, Math.min(poll_interval_ms || 300, 2000));
|
|
253
|
+
const maxAttempts = (retry && retry.max_attempts) ? Math.max(1, retry.max_attempts) : 1;
|
|
254
|
+
const backoff = (retry && retry.backoff_ms) ? Math.max(0, retry.backoff_ms) : 0;
|
|
255
|
+
let attempts = 0;
|
|
256
|
+
let totalPollCount = 0;
|
|
257
|
+
// Precompute normalized selector values and helpers (constant across polls)
|
|
258
|
+
const normalize = ToolsInteract._normalize;
|
|
259
|
+
const containsFlag = !!selector?.contains;
|
|
260
|
+
const selText = normalize(selector?.text);
|
|
261
|
+
const selRid = normalize(selector?.resource_id);
|
|
262
|
+
const selAid = normalize(selector?.accessibility_id);
|
|
263
|
+
try {
|
|
264
|
+
while (attempts < maxAttempts) {
|
|
265
|
+
attempts++;
|
|
266
|
+
const attemptStart = Date.now();
|
|
267
|
+
const deadline = attemptStart + (timeout_ms || 0);
|
|
268
|
+
while (Date.now() <= deadline) {
|
|
269
|
+
totalPollCount++;
|
|
270
|
+
try {
|
|
271
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
|
|
272
|
+
const elements = (tree && Array.isArray(tree.elements)) ? tree.elements : [];
|
|
273
|
+
const matches = [];
|
|
274
|
+
for (let i = 0; i < elements.length; i++) {
|
|
275
|
+
const el = elements[i];
|
|
276
|
+
let ok = true;
|
|
277
|
+
// text
|
|
278
|
+
if (selector.text !== undefined && selector.text !== null) {
|
|
279
|
+
const val = normalize(el.text || el.label || el.value || '');
|
|
280
|
+
if (containsFlag) {
|
|
281
|
+
if (!val.includes(selText))
|
|
282
|
+
ok = false;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
if (val !== selText)
|
|
286
|
+
ok = false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// resource_id
|
|
290
|
+
if (ok && selector.resource_id !== undefined && selector.resource_id !== null) {
|
|
291
|
+
const rid = normalize(el.resourceId || el.resourceID || el.id || '');
|
|
292
|
+
if (containsFlag) {
|
|
293
|
+
if (!rid.includes(selRid))
|
|
294
|
+
ok = false;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
if (rid !== selRid)
|
|
298
|
+
ok = false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// accessibility_id
|
|
302
|
+
if (ok && selector.accessibility_id !== undefined && selector.accessibility_id !== null) {
|
|
303
|
+
const aid = normalize(el.contentDescription || el.contentDesc || el.accessibilityLabel || el.label || '');
|
|
304
|
+
if (containsFlag) {
|
|
305
|
+
if (!aid.includes(selAid))
|
|
306
|
+
ok = false;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
if (aid !== selAid)
|
|
310
|
+
ok = false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (ok)
|
|
314
|
+
matches.push({ el, idx: i });
|
|
315
|
+
}
|
|
316
|
+
// Evaluate condition
|
|
317
|
+
const matchedCount = matches.length;
|
|
318
|
+
const pickIndex = (typeof match?.index === 'number') ? match.index : undefined;
|
|
319
|
+
let chosen = null;
|
|
320
|
+
if (matches.length > 0) {
|
|
321
|
+
if (pickIndex !== undefined) {
|
|
322
|
+
// If a specific index is requested but out of bounds, treat as not matched for this poll (deterministic)
|
|
323
|
+
if (pickIndex >= 0 && pickIndex < matches.length)
|
|
324
|
+
chosen = matches[pickIndex];
|
|
325
|
+
else
|
|
326
|
+
chosen = null;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
chosen = matches[0];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
chosen = null;
|
|
334
|
+
}
|
|
335
|
+
let conditionMet = false;
|
|
336
|
+
if (condition === 'exists') {
|
|
337
|
+
// when an index is specified, existence requires that specific index be present
|
|
338
|
+
conditionMet = (pickIndex !== undefined) ? (chosen !== null) : (matchedCount >= 1);
|
|
339
|
+
}
|
|
340
|
+
else if (condition === 'not_exists') {
|
|
341
|
+
// when an index is specified, not_exists is true if that index is absent
|
|
342
|
+
conditionMet = (pickIndex !== undefined) ? (chosen === null) : (matchedCount === 0);
|
|
343
|
+
}
|
|
344
|
+
else if (condition === 'visible') {
|
|
345
|
+
if (chosen) {
|
|
346
|
+
const b = chosen.el.bounds;
|
|
347
|
+
const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1]);
|
|
348
|
+
conditionMet = visibleFlag;
|
|
349
|
+
}
|
|
350
|
+
else
|
|
351
|
+
conditionMet = false;
|
|
352
|
+
}
|
|
353
|
+
else if (condition === 'clickable') {
|
|
354
|
+
if (chosen) {
|
|
355
|
+
const b = chosen.el.bounds;
|
|
356
|
+
const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1]);
|
|
357
|
+
const enabled = !!chosen.el.enabled;
|
|
358
|
+
const clickable = !!chosen.el.clickable || !!chosen.el._interactable;
|
|
359
|
+
conditionMet = visibleFlag && enabled && clickable;
|
|
360
|
+
}
|
|
361
|
+
else
|
|
362
|
+
conditionMet = false;
|
|
363
|
+
}
|
|
364
|
+
if (conditionMet) {
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
const latency_ms = now - overallStart;
|
|
367
|
+
// Build element output per spec
|
|
368
|
+
const outEl = chosen ? {
|
|
369
|
+
text: chosen.el.text ?? null,
|
|
370
|
+
resource_id: chosen.el.resourceId ?? chosen.el.resourceID ?? chosen.el.id ?? null,
|
|
371
|
+
accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
|
|
372
|
+
class: chosen.el.type ?? chosen.el.class ?? null,
|
|
373
|
+
bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
|
|
374
|
+
index: chosen.idx
|
|
375
|
+
} : null;
|
|
376
|
+
return {
|
|
377
|
+
status: 'success',
|
|
378
|
+
matched: matchedCount,
|
|
379
|
+
element: outEl,
|
|
380
|
+
metrics: { latency_ms, poll_count: totalPollCount, attempts }
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
// Non-fatal per-poll error; record and continue
|
|
386
|
+
console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e));
|
|
387
|
+
}
|
|
388
|
+
// Sleep until next poll
|
|
389
|
+
await new Promise(r => setTimeout(r, effectivePoll || 50));
|
|
390
|
+
}
|
|
391
|
+
// Attempt timed out; if more attempts allowed, backoff then retry
|
|
392
|
+
if (attempts < maxAttempts) {
|
|
393
|
+
if (backoff > 0)
|
|
394
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
// Final failure for this call
|
|
398
|
+
const elapsed = Date.now() - overallStart;
|
|
399
|
+
return {
|
|
400
|
+
status: 'timeout',
|
|
401
|
+
error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout` },
|
|
402
|
+
metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts }
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
const elapsed = Date.now() - overallStart;
|
|
408
|
+
return { status: 'timeout', error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) }, metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts } };
|
|
409
|
+
}
|
|
221
410
|
}
|
|
222
411
|
// Helper: normalize various log objects into plain message strings for comparison
|
|
223
412
|
static _logsToMessages(logsArr) {
|
package/dist/observe/android.js
CHANGED
|
@@ -171,11 +171,13 @@ export class AndroidObserve {
|
|
|
171
171
|
return ta - tb;
|
|
172
172
|
});
|
|
173
173
|
const limited = filtered.slice(-Math.max(0, effectiveLimit));
|
|
174
|
-
|
|
174
|
+
const source = pidArg ? 'pid' : (appId ? 'package' : 'broad');
|
|
175
|
+
const meta = { pidArg, appIdProvided: !!appId, filters: { tag, level, contains, since_seconds, limit: effectiveLimit }, pidExplicit: !!pid };
|
|
176
|
+
return { device: deviceInfo, logs: limited, logCount: limited.length, source, meta };
|
|
175
177
|
}
|
|
176
178
|
catch (e) {
|
|
177
179
|
console.error("Error fetching logs:", e);
|
|
178
|
-
return { device: deviceInfo, logs: [], logCount: 0 };
|
|
180
|
+
return { device: deviceInfo, logs: [], logCount: 0, source: 'broad', meta: { error: e instanceof Error ? e.message : String(e) } };
|
|
179
181
|
}
|
|
180
182
|
}
|
|
181
183
|
async captureScreen(deviceId) {
|
package/dist/observe/index.js
CHANGED
|
@@ -46,8 +46,8 @@ export class ToolsObserve {
|
|
|
46
46
|
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
|
|
47
47
|
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
|
|
48
48
|
if (anyFilterApplied && logs.length === 0)
|
|
49
|
-
return { device: response.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
|
|
50
|
-
return { device: response.device, logs, crashLines, logCount: response.logCount };
|
|
49
|
+
return { device: response.device, logs: [], crashLines: [], logCount: 0, source: response.source, meta: response.meta, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
|
|
50
|
+
return { device: response.device, logs, crashLines, logCount: response.logCount, source: response.source, meta: response.meta };
|
|
51
51
|
}
|
|
52
52
|
else {
|
|
53
53
|
const resp = await observe.getLogs(filters);
|
|
@@ -55,8 +55,8 @@ export class ToolsObserve {
|
|
|
55
55
|
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
|
|
56
56
|
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
|
|
57
57
|
if (anyFilterApplied && logs.length === 0)
|
|
58
|
-
return { device: resp.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
|
|
59
|
-
return { device: resp.device, logs, crashLines, logCount: resp.logCount };
|
|
58
|
+
return { device: resp.device, logs: [], crashLines: [], logCount: 0, source: resp.source, meta: resp.meta, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
|
|
59
|
+
return { device: resp.device, logs, crashLines, logCount: resp.logCount, source: resp.source, meta: resp.meta };
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
|
package/dist/observe/ios.js
CHANGED
|
@@ -101,16 +101,41 @@ export class iOSObserve {
|
|
|
101
101
|
// default to last 60s to keep quick
|
|
102
102
|
args.push('--last', '60s');
|
|
103
103
|
}
|
|
104
|
+
let processNameUsed = undefined;
|
|
104
105
|
if (appId) {
|
|
105
106
|
validateBundleId(appId);
|
|
106
|
-
//
|
|
107
|
-
|
|
107
|
+
// prefer matching the simple process name (last segment of bundle id), but also match full bundle id in subsystem
|
|
108
|
+
const parts = appId.split('.');
|
|
109
|
+
const simpleName = parts[parts.length - 1];
|
|
110
|
+
processNameUsed = simpleName;
|
|
111
|
+
// predicate: match process by simple name or full bundle id, or subsystem contains bundle id
|
|
112
|
+
args.push('--predicate', `process == "${simpleName}" or process == "${appId}" or subsystem contains "${appId}"`);
|
|
108
113
|
}
|
|
109
114
|
else if (tag) {
|
|
110
115
|
// predicate by subsystem/category
|
|
111
116
|
args.push('--predicate', `subsystem contains "${tag}"`);
|
|
112
117
|
}
|
|
113
118
|
try {
|
|
119
|
+
// Attempt pid detection if appId provided and no explicit pid supplied — prefer process name derived from bundle id
|
|
120
|
+
let detectedPid = null;
|
|
121
|
+
if (appId && !pid) {
|
|
122
|
+
const parts = appId.split('.');
|
|
123
|
+
const simpleName = parts[parts.length - 1];
|
|
124
|
+
try {
|
|
125
|
+
const pgrepRes = await execCommand(['simctl', 'spawn', deviceId, 'pgrep', '-f', simpleName], deviceId);
|
|
126
|
+
const out = pgrepRes && pgrepRes.output ? pgrepRes.output.trim() : '';
|
|
127
|
+
const firstLine = out.split(/\r?\n/).find(Boolean);
|
|
128
|
+
if (firstLine) {
|
|
129
|
+
const n = Number(firstLine.trim());
|
|
130
|
+
if (!isNaN(n) && n > 0)
|
|
131
|
+
detectedPid = n;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// ignore pgrep failures — we'll fall back to process/bundle matching
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const effectivePid = pid || detectedPid || null;
|
|
114
139
|
const result = await execCommand(args, deviceId);
|
|
115
140
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
116
141
|
const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : [];
|
|
@@ -187,12 +212,13 @@ export class iOSObserve {
|
|
|
187
212
|
// tag filter
|
|
188
213
|
if (tag)
|
|
189
214
|
filtered = filtered.filter(e => e.tag && e.tag.includes(tag));
|
|
190
|
-
// pid filter
|
|
191
|
-
|
|
192
|
-
|
|
215
|
+
// pid filter (use detected/effective pid if available)
|
|
216
|
+
const pidToFilter = effectivePid;
|
|
217
|
+
if (pidToFilter)
|
|
218
|
+
filtered = filtered.filter(e => e.pid === pidToFilter);
|
|
193
219
|
// If appId present but no predicate returned lines, try substring match
|
|
194
220
|
if (appId && filtered.length === 0) {
|
|
195
|
-
const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)));
|
|
221
|
+
const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)) || (e.message && processNameUsed && e.message.includes(processNameUsed)));
|
|
196
222
|
if (matched.length > 0)
|
|
197
223
|
filtered = matched;
|
|
198
224
|
}
|
|
@@ -202,13 +228,15 @@ export class iOSObserve {
|
|
|
202
228
|
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
|
203
229
|
return ta - tb;
|
|
204
230
|
});
|
|
231
|
+
const source = pidToFilter ? 'pid' : (appId ? 'process' : 'broad');
|
|
232
|
+
const meta = { appIdProvided: !!appId, processNameUsed: processNameUsed || null, detectedPid: detectedPid || null, filters: { tag, level, contains, since_seconds, limit: effectiveLimit }, pidExplicit: !!pid };
|
|
205
233
|
const limited = filtered.slice(-Math.max(0, effectiveLimit));
|
|
206
|
-
return { device, logs: limited, logCount: limited.length };
|
|
234
|
+
return { device, logs: limited, logCount: limited.length, source, meta };
|
|
207
235
|
}
|
|
208
236
|
catch (err) {
|
|
209
237
|
console.error('iOS getLogs failed:', err);
|
|
210
238
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
211
|
-
return { device, logs: [], logCount: 0 };
|
|
239
|
+
return { device, logs: [], logCount: 0, source: 'broad', meta: { error: err instanceof Error ? err.message : String(err) } };
|
|
212
240
|
}
|
|
213
241
|
}
|
|
214
242
|
async captureScreenshot(deviceId = "booted") {
|
|
@@ -383,7 +411,8 @@ export class iOSObserve {
|
|
|
383
411
|
}
|
|
384
412
|
async startLogStream(bundleId, deviceId = 'booted', sessionId = 'default') {
|
|
385
413
|
try {
|
|
386
|
-
const
|
|
414
|
+
const simple = bundleId.split('.').pop() || bundleId;
|
|
415
|
+
const predicate = `process == "${simple}" or process == "${bundleId}" or subsystem contains "${bundleId}"`;
|
|
387
416
|
if (iosActiveLogStreams.has(sessionId)) {
|
|
388
417
|
try {
|
|
389
418
|
iosActiveLogStreams.get(sessionId).proc.kill();
|
package/dist/server.js
CHANGED
|
@@ -322,17 +322,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
322
322
|
},
|
|
323
323
|
{
|
|
324
324
|
name: "wait_for_ui",
|
|
325
|
-
description: "
|
|
325
|
+
description: "Deterministic UI wait primitive. Waits for selector condition with retries and backoff.",
|
|
326
326
|
inputSchema: {
|
|
327
327
|
type: "object",
|
|
328
328
|
properties: {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
329
|
+
selector: {
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: {
|
|
332
|
+
text: { type: "string" },
|
|
333
|
+
resource_id: { type: "string" },
|
|
334
|
+
accessibility_id: { type: "string" },
|
|
335
|
+
contains: { type: "boolean", description: "When true, perform substring matching", default: false }
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
condition: { type: "string", enum: ["exists", "not_exists", "visible", "clickable"], default: "exists" },
|
|
339
|
+
timeout_ms: { type: "number", default: 60000 },
|
|
340
|
+
poll_interval_ms: { type: "number", default: 300 },
|
|
341
|
+
match: { type: "object", properties: { index: { type: "number" } } },
|
|
342
|
+
retry: { type: "object", properties: { max_attempts: { type: "number", default: 1 }, backoff_ms: { type: "number", default: 0 } } },
|
|
336
343
|
platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override" },
|
|
337
344
|
deviceId: { type: "string", description: "Optional device serial/udid" }
|
|
338
345
|
}
|
|
@@ -574,7 +581,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
574
581
|
const filtered = !!(pid || tag || level || contains || since_seconds || appId);
|
|
575
582
|
return {
|
|
576
583
|
content: [
|
|
577
|
-
{ type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []) } }, null, 2) },
|
|
584
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []), source: res.source, meta: res.meta || {} } }, null, 2) },
|
|
578
585
|
{ type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
|
|
579
586
|
]
|
|
580
587
|
};
|
|
@@ -629,8 +636,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
629
636
|
return wrapResponse(res);
|
|
630
637
|
}
|
|
631
638
|
if (name === "wait_for_ui") {
|
|
632
|
-
const {
|
|
633
|
-
const res = await ToolsInteract.waitForUIHandler({
|
|
639
|
+
const { selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry, platform, deviceId } = (args || {});
|
|
640
|
+
const res = await ToolsInteract.waitForUIHandler({ selector, condition, timeout_ms, poll_interval_ms, match, retry, platform, deviceId });
|
|
634
641
|
return wrapResponse(res);
|
|
635
642
|
}
|
|
636
643
|
if (name === "find_element") {
|
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.21.5]
|
|
6
|
+
- Fixed incorrect timeout
|
|
7
|
+
|
|
8
|
+
## [0.21.4]
|
|
9
|
+
- updated `wait_for_ui` with better contract and observability
|
|
10
|
+
- update `get_logs` to get better output
|
|
11
|
+
|
|
5
12
|
## [0.21.3]
|
|
6
13
|
- Added structured logs
|
|
7
14
|
|
package/docs/tools/observe.md
CHANGED
|
@@ -4,7 +4,7 @@ Tools that retrieve device state, logs, screenshots and UI hierarchies.
|
|
|
4
4
|
|
|
5
5
|
## get_logs
|
|
6
6
|
|
|
7
|
-
Fetch recent logs as structured entries optimized for AI agents.
|
|
7
|
+
Fetch recent logs as structured entries optimized for AI agents. Use logs as a debugging aid only — prefer UI validation (wait_for_ui) first.
|
|
8
8
|
|
|
9
9
|
Input (example):
|
|
10
10
|
|
|
@@ -16,16 +16,26 @@ Defaults:
|
|
|
16
16
|
|
|
17
17
|
- No filters → return the most recent 50 log entries (app-scoped if appId provided), across all levels.
|
|
18
18
|
|
|
19
|
+
When to use get_logs:
|
|
20
|
+
|
|
21
|
+
- After a UI validation (wait_for_ui) fails to confirm the expected outcome.
|
|
22
|
+
- When you suspect a crash, error, or silent failure that the UI doesn't expose.
|
|
23
|
+
- To provide additional debugging context correlated with an action.
|
|
24
|
+
|
|
25
|
+
Do NOT use get_logs as the primary signal for success/failure, or call it repeatedly without new actions.
|
|
26
|
+
|
|
19
27
|
Response (structured):
|
|
20
28
|
|
|
21
29
|
```json
|
|
22
|
-
{ "device": { "platform": "android", "id": "emulator-5554" }, "logs": [ { "timestamp": "2026-03-30T16:00:00.000Z", "level": "ERROR", "tag": "MyTag", "pid": 1234, "message": "Something failed" } ], "
|
|
30
|
+
{ "device": { "platform": "android", "id": "emulator-5554" }, "logs": [ { "timestamp": "2026-03-30T16:00:00.000Z", "level": "ERROR", "tag": "MyTag", "pid": 1234, "message": "Something failed" } ], "logCount": 1, "source": "pid|package|process|broad", "meta": { "filters": { "tag": "MyTag", "level": "ERROR" }, "pidArg": 1234 } }
|
|
23
31
|
```
|
|
24
32
|
|
|
25
33
|
Notes:
|
|
26
34
|
|
|
27
35
|
- Each log entry: timestamp (ISO), level (VERBOSE|DEBUG|INFO|WARN|ERROR), tag (string), pid (number|null), message (string).
|
|
28
|
-
- Logs ordered oldest → newest.
|
|
36
|
+
- Logs ordered oldest → newest. logCount equals number of entries returned.
|
|
37
|
+
- `source`: indicates how logs were filtered at collection time. Values: `pid` (filtered by process id), `package` / `process` (filtered by app/package/bundle), or `broad` (unfiltered system logs).
|
|
38
|
+
- `meta`: debugging information about filters and collection method (e.g., pid detection, effective limit).
|
|
29
39
|
- Supported filters: pid, tag, level, contains, since_seconds, limit.
|
|
30
40
|
- Platform behaviour: Android uses `adb logcat` with source-side filters where possible; iOS uses unified logging (`log show`/simctl) and maps subsystem/category → tag.
|
|
31
41
|
- Errors are returned as structured objects with `error.code` and `error.message`. Possible codes: LOGS_UNAVAILABLE, INVALID_FILTER, PLATFORM_NOT_SUPPORTED, INTERNAL_ERROR.
|
package/package.json
CHANGED
package/src/interact/index.ts
CHANGED
|
@@ -32,6 +32,12 @@ interface UiElement {
|
|
|
32
32
|
|
|
33
33
|
export class ToolsInteract {
|
|
34
34
|
|
|
35
|
+
private static _normalize(s: any): string {
|
|
36
|
+
if (s === null || s === undefined) return ''
|
|
37
|
+
try { return String(s).toLowerCase().trim() } catch { return '' }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
35
41
|
private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
|
|
36
42
|
const effectivePlatform = platform || 'android'
|
|
37
43
|
const resolved = await resolveTargetDevice({ platform: effectivePlatform as 'android' | 'ios', deviceId })
|
|
@@ -68,7 +74,7 @@ export class ToolsInteract {
|
|
|
68
74
|
// Try to use observe layer to fetch the current UI tree and perform a fast semantic search
|
|
69
75
|
const start = Date.now()
|
|
70
76
|
const deadline = start + timeoutMs
|
|
71
|
-
const normalize =
|
|
77
|
+
const normalize = ToolsInteract._normalize
|
|
72
78
|
|
|
73
79
|
const q = normalize(query)
|
|
74
80
|
if (!q) return { found: false, error: 'Empty query' }
|
|
@@ -218,9 +224,188 @@ export class ToolsInteract {
|
|
|
218
224
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal }
|
|
219
225
|
}
|
|
220
226
|
|
|
221
|
-
static async waitForUIHandler({
|
|
222
|
-
|
|
223
|
-
|
|
227
|
+
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
|
|
228
|
+
const overallStart = Date.now()
|
|
229
|
+
|
|
230
|
+
// Validate selector: require at least one non-empty field (text, resource_id, or accessibility_id)
|
|
231
|
+
const hasText = typeof selector?.text === 'string' && selector.text.trim().length > 0;
|
|
232
|
+
const hasResId = typeof selector?.resource_id === 'string' && selector.resource_id.trim().length > 0;
|
|
233
|
+
const hasAccId = typeof selector?.accessibility_id === 'string' && selector.accessibility_id.trim().length > 0;
|
|
234
|
+
|
|
235
|
+
if (!hasText && !hasResId && !hasAccId) {
|
|
236
|
+
return {
|
|
237
|
+
status: 'timeout',
|
|
238
|
+
error: {
|
|
239
|
+
code: 'INVALID_SELECTOR',
|
|
240
|
+
message: 'Selector must include at least one non-empty field: text, resource_id, or accessibility_id'
|
|
241
|
+
},
|
|
242
|
+
metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Validate condition
|
|
247
|
+
if (!['exists','not_exists','visible','clickable'].includes(condition)) {
|
|
248
|
+
return { status: 'timeout', error: { code: 'INVALID_CONDITION', message: `Unsupported condition: ${condition}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Platform check
|
|
252
|
+
if (platform && !['android','ios'].includes(platform)) {
|
|
253
|
+
return { status: 'timeout', error: { code: 'PLATFORM_NOT_SUPPORTED', message: `Unsupported platform: ${platform}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const effectivePoll = Math.max(50, Math.min(poll_interval_ms || 300, 2000))
|
|
257
|
+
const maxAttempts = (retry && retry.max_attempts) ? Math.max(1, retry.max_attempts) : 1
|
|
258
|
+
const backoff = (retry && retry.backoff_ms) ? Math.max(0, retry.backoff_ms) : 0
|
|
259
|
+
|
|
260
|
+
let attempts = 0
|
|
261
|
+
let totalPollCount = 0
|
|
262
|
+
|
|
263
|
+
// Precompute normalized selector values and helpers (constant across polls)
|
|
264
|
+
const normalize = ToolsInteract._normalize
|
|
265
|
+
const containsFlag = !!selector?.contains
|
|
266
|
+
const selText = normalize(selector?.text)
|
|
267
|
+
const selRid = normalize(selector?.resource_id)
|
|
268
|
+
const selAid = normalize(selector?.accessibility_id)
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
while (attempts < maxAttempts) {
|
|
272
|
+
attempts++
|
|
273
|
+
const attemptStart = Date.now()
|
|
274
|
+
const deadline = attemptStart + (timeout_ms || 0)
|
|
275
|
+
|
|
276
|
+
while (Date.now() <= deadline) {
|
|
277
|
+
totalPollCount++
|
|
278
|
+
try {
|
|
279
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
|
|
280
|
+
const elements = (tree && Array.isArray(tree.elements)) ? tree.elements as any[] : []
|
|
281
|
+
|
|
282
|
+
const matches: { el: any, idx: number }[] = []
|
|
283
|
+
|
|
284
|
+
for (let i = 0; i < elements.length; i++) {
|
|
285
|
+
const el = elements[i]
|
|
286
|
+
let ok = true
|
|
287
|
+
|
|
288
|
+
// text
|
|
289
|
+
if (selector.text !== undefined && selector.text !== null) {
|
|
290
|
+
const val = normalize(el.text || el.label || el.value || '')
|
|
291
|
+
if (containsFlag) {
|
|
292
|
+
if (!val.includes(selText)) ok = false
|
|
293
|
+
} else {
|
|
294
|
+
if (val !== selText) ok = false
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// resource_id
|
|
299
|
+
if (ok && selector.resource_id !== undefined && selector.resource_id !== null) {
|
|
300
|
+
const rid = normalize(el.resourceId || el.resourceID || el.id || '')
|
|
301
|
+
if (containsFlag) {
|
|
302
|
+
if (!rid.includes(selRid)) ok = false
|
|
303
|
+
} else {
|
|
304
|
+
if (rid !== selRid) ok = false
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// accessibility_id
|
|
309
|
+
if (ok && selector.accessibility_id !== undefined && selector.accessibility_id !== null) {
|
|
310
|
+
const aid = normalize(el.contentDescription || el.contentDesc || el.accessibilityLabel || el.label || '')
|
|
311
|
+
if (containsFlag) {
|
|
312
|
+
if (!aid.includes(selAid)) ok = false
|
|
313
|
+
} else {
|
|
314
|
+
if (aid !== selAid) ok = false
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (ok) matches.push({ el, idx: i })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Evaluate condition
|
|
322
|
+
const matchedCount = matches.length
|
|
323
|
+
const pickIndex = (typeof match?.index === 'number') ? match!.index as number : undefined
|
|
324
|
+
let chosen: { el: any, idx: number } | null = null
|
|
325
|
+
if (matches.length > 0) {
|
|
326
|
+
if (pickIndex !== undefined) {
|
|
327
|
+
// If a specific index is requested but out of bounds, treat as not matched for this poll (deterministic)
|
|
328
|
+
if (pickIndex >= 0 && pickIndex < matches.length) chosen = matches[pickIndex]
|
|
329
|
+
else chosen = null
|
|
330
|
+
} else {
|
|
331
|
+
chosen = matches[0]
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
chosen = null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let conditionMet = false
|
|
338
|
+
if (condition === 'exists') {
|
|
339
|
+
// when an index is specified, existence requires that specific index be present
|
|
340
|
+
conditionMet = (pickIndex !== undefined) ? (chosen !== null) : (matchedCount >= 1)
|
|
341
|
+
} else if (condition === 'not_exists') {
|
|
342
|
+
// when an index is specified, not_exists is true if that index is absent
|
|
343
|
+
conditionMet = (pickIndex !== undefined) ? (chosen === null) : (matchedCount === 0)
|
|
344
|
+
} else if (condition === 'visible') {
|
|
345
|
+
if (chosen) {
|
|
346
|
+
const b = chosen.el.bounds
|
|
347
|
+
const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
|
|
348
|
+
conditionMet = visibleFlag
|
|
349
|
+
} else conditionMet = false
|
|
350
|
+
} else if (condition === 'clickable') {
|
|
351
|
+
if (chosen) {
|
|
352
|
+
const b = chosen.el.bounds
|
|
353
|
+
const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
|
|
354
|
+
const enabled = !!chosen.el.enabled
|
|
355
|
+
const clickable = !!chosen.el.clickable || !!chosen.el._interactable
|
|
356
|
+
conditionMet = visibleFlag && enabled && clickable
|
|
357
|
+
} else conditionMet = false
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (conditionMet) {
|
|
361
|
+
const now = Date.now()
|
|
362
|
+
const latency_ms = now - overallStart
|
|
363
|
+
// Build element output per spec
|
|
364
|
+
const outEl = chosen ? {
|
|
365
|
+
text: chosen.el.text ?? null,
|
|
366
|
+
resource_id: chosen.el.resourceId ?? chosen.el.resourceID ?? chosen.el.id ?? null,
|
|
367
|
+
accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
|
|
368
|
+
class: chosen.el.type ?? chosen.el.class ?? null,
|
|
369
|
+
bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
|
|
370
|
+
index: chosen.idx
|
|
371
|
+
} : null
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
status: 'success',
|
|
375
|
+
matched: matchedCount,
|
|
376
|
+
element: outEl,
|
|
377
|
+
metrics: { latency_ms, poll_count: totalPollCount, attempts }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
} catch (e) {
|
|
382
|
+
// Non-fatal per-poll error; record and continue
|
|
383
|
+
console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Sleep until next poll
|
|
387
|
+
await new Promise(r => setTimeout(r, effectivePoll || 50))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Attempt timed out; if more attempts allowed, backoff then retry
|
|
391
|
+
if (attempts < maxAttempts) {
|
|
392
|
+
if (backoff > 0) await new Promise(r => setTimeout(r, backoff))
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Final failure for this call
|
|
397
|
+
const elapsed = Date.now() - overallStart
|
|
398
|
+
return {
|
|
399
|
+
status: 'timeout',
|
|
400
|
+
error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout` },
|
|
401
|
+
metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const elapsed = Date.now() - overallStart
|
|
407
|
+
return { status: 'timeout', error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) }, metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts } }
|
|
408
|
+
}
|
|
224
409
|
}
|
|
225
410
|
|
|
226
411
|
// Helper: normalize various log objects into plain message strings for comparison
|
package/src/observe/android.ts
CHANGED
|
@@ -183,10 +183,12 @@ export class AndroidObserve {
|
|
|
183
183
|
|
|
184
184
|
const limited = filtered.slice(-Math.max(0, effectiveLimit))
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
const source = pidArg ? 'pid' : (appId ? 'package' : 'broad')
|
|
187
|
+
const meta = { pidArg, appIdProvided: !!appId, filters: { tag, level, contains, since_seconds, limit: effectiveLimit }, pidExplicit: !!pid }
|
|
188
|
+
return { device: deviceInfo, logs: limited, logCount: limited.length, source, meta }
|
|
187
189
|
} catch (e) {
|
|
188
190
|
console.error("Error fetching logs:", e)
|
|
189
|
-
return { device: deviceInfo, logs: [], logCount: 0 }
|
|
191
|
+
return { device: deviceInfo, logs: [], logCount: 0, source: 'broad', meta: { error: e instanceof Error ? e.message : String(e) } }
|
|
190
192
|
}
|
|
191
193
|
}
|
|
192
194
|
|
package/src/observe/index.ts
CHANGED
|
@@ -52,15 +52,15 @@ export class ToolsObserve {
|
|
|
52
52
|
const logs = Array.isArray(response.logs) ? response.logs : []
|
|
53
53
|
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message))
|
|
54
54
|
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds)
|
|
55
|
-
if (anyFilterApplied && logs.length === 0) return { device: response.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
|
|
56
|
-
return { device: response.device, logs, crashLines, logCount: response.logCount }
|
|
55
|
+
if (anyFilterApplied && logs.length === 0) return { device: response.device, logs: [], crashLines: [], logCount: 0, source: response.source, meta: response.meta, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
|
|
56
|
+
return { device: response.device, logs, crashLines, logCount: response.logCount, source: response.source, meta: response.meta }
|
|
57
57
|
} else {
|
|
58
58
|
const resp = await (observe as iOSObserve).getLogs(filters)
|
|
59
59
|
const logs = Array.isArray(resp.logs) ? resp.logs : []
|
|
60
60
|
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message))
|
|
61
61
|
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds)
|
|
62
|
-
if (anyFilterApplied && logs.length === 0) return { device: resp.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
|
|
63
|
-
return { device: resp.device, logs, crashLines, logCount: resp.logCount }
|
|
62
|
+
if (anyFilterApplied && logs.length === 0) return { device: resp.device, logs: [], crashLines: [], logCount: 0, source: resp.source, meta: resp.meta, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
|
|
63
|
+
return { device: resp.device, logs, crashLines, logCount: resp.logCount, source: resp.source, meta: resp.meta }
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
package/src/observe/ios.ts
CHANGED
|
@@ -136,16 +136,39 @@ export class iOSObserve {
|
|
|
136
136
|
args.push('--last', '60s')
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
let processNameUsed: string | undefined = undefined
|
|
139
140
|
if (appId) {
|
|
140
141
|
validateBundleId(appId)
|
|
141
|
-
//
|
|
142
|
-
|
|
142
|
+
// prefer matching the simple process name (last segment of bundle id), but also match full bundle id in subsystem
|
|
143
|
+
const parts = appId.split('.')
|
|
144
|
+
const simpleName = parts[parts.length - 1]
|
|
145
|
+
processNameUsed = simpleName
|
|
146
|
+
// predicate: match process by simple name or full bundle id, or subsystem contains bundle id
|
|
147
|
+
args.push('--predicate', `process == "${simpleName}" or process == "${appId}" or subsystem contains "${appId}"`)
|
|
143
148
|
} else if (tag) {
|
|
144
149
|
// predicate by subsystem/category
|
|
145
150
|
args.push('--predicate', `subsystem contains "${tag}"`)
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
try {
|
|
154
|
+
// Attempt pid detection if appId provided and no explicit pid supplied — prefer process name derived from bundle id
|
|
155
|
+
let detectedPid: number | null = null
|
|
156
|
+
if (appId && !pid) {
|
|
157
|
+
const parts = appId.split('.')
|
|
158
|
+
const simpleName = parts[parts.length - 1]
|
|
159
|
+
try {
|
|
160
|
+
const pgrepRes = await execCommand(['simctl','spawn', deviceId, 'pgrep', '-f', simpleName], deviceId)
|
|
161
|
+
const out = pgrepRes && pgrepRes.output ? pgrepRes.output.trim() : ''
|
|
162
|
+
const firstLine = out.split(/\r?\n/).find(Boolean)
|
|
163
|
+
if (firstLine) {
|
|
164
|
+
const n = Number(firstLine.trim())
|
|
165
|
+
if (!isNaN(n) && n > 0) detectedPid = n
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// ignore pgrep failures — we'll fall back to process/bundle matching
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const effectivePid = pid || detectedPid || null
|
|
149
172
|
const result = await execCommand(args, deviceId)
|
|
150
173
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
151
174
|
const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
|
|
@@ -225,12 +248,13 @@ export class iOSObserve {
|
|
|
225
248
|
// tag filter
|
|
226
249
|
if (tag) filtered = filtered.filter(e => e.tag && e.tag.includes(tag))
|
|
227
250
|
|
|
228
|
-
// pid filter
|
|
229
|
-
|
|
251
|
+
// pid filter (use detected/effective pid if available)
|
|
252
|
+
const pidToFilter = effectivePid
|
|
253
|
+
if (pidToFilter) filtered = filtered.filter(e => e.pid === pidToFilter)
|
|
230
254
|
|
|
231
255
|
// If appId present but no predicate returned lines, try substring match
|
|
232
256
|
if (appId && filtered.length === 0) {
|
|
233
|
-
const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)))
|
|
257
|
+
const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)) || (e.message && processNameUsed && e.message.includes(processNameUsed)))
|
|
234
258
|
if (matched.length > 0) filtered = matched
|
|
235
259
|
}
|
|
236
260
|
|
|
@@ -241,12 +265,14 @@ export class iOSObserve {
|
|
|
241
265
|
return ta - tb
|
|
242
266
|
})
|
|
243
267
|
|
|
268
|
+
const source = pidToFilter ? 'pid' : (appId ? 'process' : 'broad')
|
|
269
|
+
const meta = { appIdProvided: !!appId, processNameUsed: processNameUsed || null, detectedPid: detectedPid || null, filters: { tag, level, contains, since_seconds, limit: effectiveLimit }, pidExplicit: !!pid }
|
|
244
270
|
const limited = filtered.slice(-Math.max(0, effectiveLimit))
|
|
245
|
-
return { device, logs: limited, logCount: limited.length }
|
|
271
|
+
return { device, logs: limited, logCount: limited.length, source, meta }
|
|
246
272
|
} catch (err) {
|
|
247
273
|
console.error('iOS getLogs failed:', err)
|
|
248
274
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
249
|
-
return { device, logs: [], logCount: 0 }
|
|
275
|
+
return { device, logs: [], logCount: 0, source: 'broad', meta: { error: err instanceof Error ? err.message : String(err) } }
|
|
250
276
|
}
|
|
251
277
|
}
|
|
252
278
|
|
|
@@ -439,7 +465,8 @@ export class iOSObserve {
|
|
|
439
465
|
|
|
440
466
|
async startLogStream(bundleId: string, deviceId: string = 'booted', sessionId: string = 'default') : Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
|
|
441
467
|
try {
|
|
442
|
-
const
|
|
468
|
+
const simple = bundleId.split('.').pop() || bundleId
|
|
469
|
+
const predicate = `process == "${simple}" or process == "${bundleId}" or subsystem contains "${bundleId}"`
|
|
443
470
|
|
|
444
471
|
if (iosActiveLogStreams.has(sessionId)) {
|
|
445
472
|
try { iosActiveLogStreams.get(sessionId)!.proc.kill() } catch {}
|
package/src/server.ts
CHANGED
|
@@ -347,17 +347,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
347
347
|
},
|
|
348
348
|
{
|
|
349
349
|
name: "wait_for_ui",
|
|
350
|
-
description: "
|
|
350
|
+
description: "Deterministic UI wait primitive. Waits for selector condition with retries and backoff.",
|
|
351
351
|
inputSchema: {
|
|
352
352
|
type: "object",
|
|
353
353
|
properties: {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
354
|
+
selector: {
|
|
355
|
+
type: "object",
|
|
356
|
+
properties: {
|
|
357
|
+
text: { type: "string" },
|
|
358
|
+
resource_id: { type: "string" },
|
|
359
|
+
accessibility_id: { type: "string" },
|
|
360
|
+
contains: { type: "boolean", description: "When true, perform substring matching", default: false }
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
condition: { type: "string", enum: ["exists","not_exists","visible","clickable"], default: "exists" },
|
|
364
|
+
timeout_ms: { type: "number", default: 60000 },
|
|
365
|
+
poll_interval_ms: { type: "number", default: 300 },
|
|
366
|
+
match: { type: "object", properties: { index: { type: "number" } } },
|
|
367
|
+
retry: { type: "object", properties: { max_attempts: { type: "number", default: 1 }, backoff_ms: { type: "number", default: 0 } } },
|
|
361
368
|
platform: { type: "string", enum: ["android","ios"], description: "Optional platform override" },
|
|
362
369
|
deviceId: { type: "string", description: "Optional device serial/udid" }
|
|
363
370
|
}
|
|
@@ -365,6 +372,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
365
372
|
},
|
|
366
373
|
|
|
367
374
|
|
|
375
|
+
|
|
368
376
|
{
|
|
369
377
|
name: "find_element",
|
|
370
378
|
description: "Find a UI element by semantic query (text, content-desc, resource-id, class). Returns best match.",
|
|
@@ -614,7 +622,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
614
622
|
const filtered = !!(pid || tag || level || contains || since_seconds || appId)
|
|
615
623
|
return {
|
|
616
624
|
content: [
|
|
617
|
-
{ type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []) } }, null, 2) },
|
|
625
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []), source: res.source, meta: res.meta || {} } }, null, 2) },
|
|
618
626
|
{ type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
|
|
619
627
|
]
|
|
620
628
|
}
|
|
@@ -680,8 +688,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
680
688
|
|
|
681
689
|
|
|
682
690
|
if (name === "wait_for_ui") {
|
|
683
|
-
const {
|
|
684
|
-
const res = await ToolsInteract.waitForUIHandler({
|
|
691
|
+
const { selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry, platform, deviceId } = (args || {}) as any
|
|
692
|
+
const res = await ToolsInteract.waitForUIHandler({ selector, condition, timeout_ms, poll_interval_ms, match, retry, platform, deviceId })
|
|
685
693
|
return wrapResponse(res)
|
|
686
694
|
}
|
|
687
695
|
|
package/src/types.ts
CHANGED
|
@@ -48,6 +48,10 @@ export interface GetLogsResponse {
|
|
|
48
48
|
device: DeviceInfo;
|
|
49
49
|
logs: StructuredLogEntry[];
|
|
50
50
|
logCount: number;
|
|
51
|
+
// Source indicates the filtering method used: 'pid', 'package'/'process', or 'broad'
|
|
52
|
+
source?: string;
|
|
53
|
+
// Meta contains debugging information about how logs were collected and filters applied
|
|
54
|
+
meta?: Record<string, any>;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
export interface GetCrashResponse {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
2
|
+
import * as Observe from '../../../src/observe/index.js'
|
|
3
|
+
import assert from 'assert'
|
|
4
|
+
|
|
5
|
+
async function run() {
|
|
6
|
+
console.log('Starting wait_for_ui contract tests...')
|
|
7
|
+
const orig = (Observe as any).ToolsObserve.getUITreeHandler
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
// success shape
|
|
11
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'OK', resourceId: 'rid', contentDescription: 'acc', type: 'TextView', bounds: [0,0,10,10], visible: true, clickable: false, enabled: true } ] })
|
|
12
|
+
const s = await ToolsInteract.waitForUIHandler({ selector: { text: 'OK' }, condition: 'exists', timeout_ms: 500, poll_interval_ms: 50, platform: 'android' })
|
|
13
|
+
// Assert contract fields for success
|
|
14
|
+
assert.strictEqual(s.status, 'success', 'status must be success')
|
|
15
|
+
assert.strictEqual(typeof s.matched, 'number', 'matched must be number')
|
|
16
|
+
assert.ok(s.element, 'element must be present')
|
|
17
|
+
assert.ok(s.metrics && typeof s.metrics.latency_ms === 'number' && typeof s.metrics.poll_count === 'number' && typeof s.metrics.attempts === 'number', 'metrics must include latency_ms, poll_count, attempts')
|
|
18
|
+
assert.ok(['string','object'].includes(typeof s.element.bounds) || Array.isArray(s.element.bounds), 'element.bounds must be present')
|
|
19
|
+
|
|
20
|
+
// timeout shape
|
|
21
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [] })
|
|
22
|
+
const t = await ToolsInteract.waitForUIHandler({ selector: { text: 'Nope' }, condition: 'exists', timeout_ms: 200, poll_interval_ms: 50, platform: 'android' })
|
|
23
|
+
assert.strictEqual(t.status, 'timeout', 'status must be timeout on no match')
|
|
24
|
+
assert.ok(t.error && t.error.code && t.error.message, 'timeout must include error with code and message')
|
|
25
|
+
assert.ok(t.metrics && typeof t.metrics.latency_ms === 'number', 'timeout metrics must include latency_ms')
|
|
26
|
+
|
|
27
|
+
console.log('wait_for_ui contract tests: PASS')
|
|
28
|
+
} finally {
|
|
29
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = orig
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
run().catch(err => { console.error('wait_for_ui_contract tests failed:', err); process.exit(1) })
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
2
|
+
import * as Observe from '../../../src/observe/index.js'
|
|
3
|
+
|
|
4
|
+
async function run() {
|
|
5
|
+
console.log('Starting new wait_for_ui unit tests...')
|
|
6
|
+
const origGetUITree = (Observe as any).ToolsObserve.getUITreeHandler
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
// Test 1: exact text match -> exists
|
|
10
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'Hello', resourceId: 'rid1', contentDescription: 'acc1', type: 'Button', bounds: [0,0,10,10], visible: true, clickable: false, enabled: true } ] })
|
|
11
|
+
const r1 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Hello' }, condition: 'exists', timeout_ms: 1000, poll_interval_ms: 50, platform: 'android' })
|
|
12
|
+
const ok1 = r1 && r1.status === 'success' && r1.matched === 1 && r1.element && r1.element.text === 'Hello'
|
|
13
|
+
console.log('Exact match exists:', ok1 ? 'PASS' : 'FAIL', JSON.stringify(r1, null, 2))
|
|
14
|
+
|
|
15
|
+
// Test 2: contains matching
|
|
16
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'Welcome User', resourceId: 'rid2', contentDescription: 'acc2', type: 'TextView', bounds: [0,0,50,10], visible: true } ] })
|
|
17
|
+
const r2 = await ToolsInteract.waitForUIHandler({ selector: { text: 'User', contains: true }, condition: 'exists', timeout_ms: 1000, poll_interval_ms: 50, platform: 'android' })
|
|
18
|
+
const ok2 = r2 && r2.status === 'success' && r2.matched === 1 && r2.element && r2.element.text && r2.element.text.includes('Welcome')
|
|
19
|
+
console.log('Contains match:', ok2 ? 'PASS' : 'FAIL', JSON.stringify(r2, null, 2))
|
|
20
|
+
|
|
21
|
+
// Test 3: visible condition
|
|
22
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'Hidden', resourceId: 'rid3', bounds: [0,0,0,0], visible: false } ] })
|
|
23
|
+
const r3 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Hidden' }, condition: 'visible', timeout_ms: 300, poll_interval_ms: 50, platform: 'android' })
|
|
24
|
+
const ok3 = r3 && r3.status === 'timeout' && r3.error && r3.error.code === 'ELEMENT_NOT_FOUND'
|
|
25
|
+
console.log('Visible negative (hidden element):', ok3 ? 'PASS' : 'FAIL', JSON.stringify(r3, null, 2))
|
|
26
|
+
|
|
27
|
+
// Test 4: clickable condition
|
|
28
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'TapMe', resourceId: 'rid4', bounds: [0,0,20,20], visible: true, clickable: true, enabled: true } ] })
|
|
29
|
+
const r4 = await ToolsInteract.waitForUIHandler({ selector: { text: 'TapMe' }, condition: 'clickable', timeout_ms: 1000, poll_interval_ms: 50, platform: 'android' })
|
|
30
|
+
const ok4 = r4 && r4.status === 'success' && r4.matched === 1 && r4.element && r4.element.index === 0
|
|
31
|
+
console.log('Clickable match:', ok4 ? 'PASS' : 'FAIL', JSON.stringify(r4, null, 2))
|
|
32
|
+
|
|
33
|
+
// Test 5: retry behavior - first attempt times out, second attempt succeeds
|
|
34
|
+
const start = Date.now()
|
|
35
|
+
let seqTree = async () => {
|
|
36
|
+
const now = Date.now()
|
|
37
|
+
// for first ~400ms return no elements, afterwards return match
|
|
38
|
+
if (now - start < 400) return { elements: [] }
|
|
39
|
+
return { elements: [ { text: 'Retried', resourceId: 'rid5', bounds: [0,0,10,10], visible: true } ] }
|
|
40
|
+
}
|
|
41
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = seqTree
|
|
42
|
+
const r5 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Retried' }, condition: 'exists', timeout_ms: 200, poll_interval_ms: 50, match: undefined, retry: { max_attempts: 3, backoff_ms: 150 }, platform: 'android' })
|
|
43
|
+
const ok5 = r5 && r5.status === 'success' && r5.metrics && r5.metrics.attempts >= 2
|
|
44
|
+
console.log('Retry behavior:', ok5 ? 'PASS' : 'FAIL', JSON.stringify(r5, null, 2))
|
|
45
|
+
|
|
46
|
+
// Test 6: timeout with no selector match -> correct error code
|
|
47
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [] })
|
|
48
|
+
const r6 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Nope' }, condition: 'exists', timeout_ms: 300, poll_interval_ms: 50, retry: { max_attempts: 1 }, platform: 'android' })
|
|
49
|
+
const ok6 = r6 && r6.status === 'timeout' && r6.error && r6.error.code === 'ELEMENT_NOT_FOUND'
|
|
50
|
+
console.log('Timeout no match:', ok6 ? 'PASS' : 'FAIL', JSON.stringify(r6, null, 2))
|
|
51
|
+
|
|
52
|
+
} finally {
|
|
53
|
+
(Observe as any).ToolsObserve.getUITreeHandler = origGetUITree
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
run().catch(err => { console.error('wait_for_ui_new tests failed:', err); process.exit(1) })
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { iOSObserve } from '../../../src/observe/ios'
|
|
2
|
+
import assert from 'assert'
|
|
3
|
+
|
|
4
|
+
// Lightweight unit tests: verify predicate construction and meta extraction logic using internal functions
|
|
5
|
+
// Since getLogs executes xcrun, run tests in SKIP_DEVICE_TESTS=1 environment by stubbing execCommand where possible.
|
|
6
|
+
|
|
7
|
+
import * as iosUtils from '../../../src/utils/ios/utils'
|
|
8
|
+
|
|
9
|
+
function stubExecCommand(original: any, expectedArgsChecker: (args: string[]) => boolean, output: string) {
|
|
10
|
+
return async function (args: string[], deviceId?: string) {
|
|
11
|
+
if (!expectedArgsChecker(args)) throw new Error('Unexpected args: ' + JSON.stringify(args))
|
|
12
|
+
return { output, device: { platform: 'ios', id: deviceId || 'booted' } }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('iOS getLogs predicate and meta', () => {
|
|
17
|
+
let obs: iOSObserve
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
obs = new iOSObserve()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('uses simple process name predicate when appId provided', async () => {
|
|
23
|
+
const bundle = 'com.ideamechanics.modul8'
|
|
24
|
+
// stub execCommand twice: first for pgrep, second for log show
|
|
25
|
+
const pgrepOutput = '12345\n'
|
|
26
|
+
const logOutput = '2026-03-31 09:21:20.085 Module[12345:678] <Info> Modul8: Test message'
|
|
27
|
+
|
|
28
|
+
const orig = (iosUtils as any).execCommand
|
|
29
|
+
try {
|
|
30
|
+
(iosUtils as any).execCommand = stubExecCommand(orig, (args) => args.includes('pgrep'), pgrepOutput)
|
|
31
|
+
// second replacement for the log show call
|
|
32
|
+
let called = false
|
|
33
|
+
(iosUtils as any).execCommand = async function (args: string[]) {
|
|
34
|
+
if (args.includes('pgrep')) return { output: pgrepOutput, device: { platform: 'ios', id: 'booted' } }
|
|
35
|
+
if (args.includes('log') && args.includes('show')) { called = true; return { output: logOutput, device: { platform: 'ios', id: 'booted' } } }
|
|
36
|
+
throw new Error('Unexpected args: ' + JSON.stringify(args))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const res = await obs.getLogs({ appId: bundle, deviceId: 'booted' })
|
|
40
|
+
assert(res.meta.processNameUsed === 'modul8' || res.meta.processNameUsed === 'Modul8' || !!res.meta.processNameUsed)
|
|
41
|
+
assert(res.meta.detectedPid === 12345)
|
|
42
|
+
assert(res.source === 'pid')
|
|
43
|
+
assert(res.logCount === 1)
|
|
44
|
+
assert(res.logs[0].message.includes('Test message'))
|
|
45
|
+
assert(called, 'log show must have been called')
|
|
46
|
+
} finally {
|
|
47
|
+
(iosUtils as any).execCommand = orig
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('falls back to broad when no appId', async () => {
|
|
52
|
+
const logOutput = '2026-03-31 09:21:20.085 SomeOther[222:333] <Info> Other: Hello'
|
|
53
|
+
const orig = (iosUtils as any).execCommand
|
|
54
|
+
try {
|
|
55
|
+
(iosUtils as any).execCommand = async function (args: string[]) {
|
|
56
|
+
if (args.includes('log') && args.includes('show')) return { output: logOutput, device: { platform: 'ios', id: 'booted' } }
|
|
57
|
+
throw new Error('Unexpected args: ' + JSON.stringify(args))
|
|
58
|
+
}
|
|
59
|
+
const obs = new iOSObserve()
|
|
60
|
+
const res = await obs.getLogs({ deviceId: 'booted' })
|
|
61
|
+
assert(res.source === 'broad')
|
|
62
|
+
assert(res.logCount === 1)
|
|
63
|
+
} finally {
|
|
64
|
+
(iosUtils as any).execCommand = orig
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
})
|
package/test/helpers/.gitkeep
DELETED
|
File without changes
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { ToolsManage } from '../../../dist/manage/index.js'
|
|
3
|
-
import path from 'path'
|
|
4
|
-
|
|
5
|
-
async function main() {
|
|
6
|
-
// Prefer a repo-local sample modul8 project if present, otherwise allow overriding via KMP_PROJECT env var
|
|
7
|
-
const defaultRelative = path.join(process.cwd(), '..', '..', '..', '..', 'test-fixtures', 'modul8')
|
|
8
|
-
const project = process.env.KMP_PROJECT || defaultRelative
|
|
9
|
-
console.log('Running KMP build+install for project', project)
|
|
10
|
-
// Use projectType=kmp and let handler pick android by default for KMP
|
|
11
|
-
// Request iOS explicitly for this run to test iOS build path
|
|
12
|
-
const res = await ToolsManage.buildAndInstallHandler({ platform: 'ios', projectPath: project, projectType: 'kmp', timeout: 600000, deviceId: undefined })
|
|
13
|
-
console.log(JSON.stringify(res, null, 2))
|
|
14
|
-
if (res.result && res.result.success) process.exit(0)
|
|
15
|
-
process.exit(1)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
main().catch(e => { console.error(e); process.exit(2) })
|