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.
@@ -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 = (s) => (s === null || s === undefined) ? '' : String(s).toLowerCase().trim();
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({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }) {
219
- // Backwards-compatible wrapper that delegates to the core waitForUICore implementation
220
- return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
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) {
@@ -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
- return { device: deviceInfo, logs: limited, logCount: limited.length };
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) {
@@ -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 }) {
@@ -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
- // constrain to subsystem or process matching appId
107
- args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`);
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
- if (pid)
192
- filtered = filtered.filter(e => e.pid === pid);
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 predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`;
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: "Wait for a UI/log/screen/idle condition with a stability window before returning success.",
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
- type: { type: "string", enum: ["ui", "log", "screen", "idle"], description: "Condition type to observe", default: "ui" },
330
- query: { type: "string", description: "Optional query string for ui/log/screen types" },
331
- timeoutMs: { type: "number", description: "Timeout in ms to wait for condition (default 30000)", default: 30000 },
332
- pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300, clamped to 250-500)", default: 300 },
333
- match: { type: "string", enum: ["present", "absent"], description: "Match mode for UI checks: 'present' or 'absent' (default 'present')", default: "present" },
334
- stability_ms: { type: "number", description: "Stability window in ms that the condition must hold before returning success (default 700)", default: 700 },
335
- includeSnapshotOnFailure: { type: "boolean", description: "Whether to include a debug snapshot on timeout (default true)", default: true },
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 { type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId } = (args || {});
633
- const res = await ToolsInteract.waitForUIHandler({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
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
 
@@ -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" } ], "count": 1, "filtered": true }
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. count equals number of entries returned. filtered is true if any filter was applied.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 = (s: any) => (s === null || s === undefined) ? '' : String(s).toLowerCase().trim()
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({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
222
- // Backwards-compatible wrapper that delegates to the core waitForUICore implementation
223
- return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
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
@@ -183,10 +183,12 @@ export class AndroidObserve {
183
183
 
184
184
  const limited = filtered.slice(-Math.max(0, effectiveLimit))
185
185
 
186
- return { device: deviceInfo, logs: limited, logCount: limited.length }
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
 
@@ -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
 
@@ -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
- // constrain to subsystem or process matching appId
142
- args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`)
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
- if (pid) filtered = filtered.filter(e => e.pid === pid)
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 predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`
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: "Wait for a UI/log/screen/idle condition with a stability window before returning success.",
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
- type: { type: "string", enum: ["ui","log","screen","idle"], description: "Condition type to observe", default: "ui" },
355
- query: { type: "string", description: "Optional query string for ui/log/screen types" },
356
- timeoutMs: { type: "number", description: "Timeout in ms to wait for condition (default 30000)", default: 30000 },
357
- pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300, clamped to 250-500)", default: 300 },
358
- match: { type: "string", enum: ["present","absent"], description: "Match mode for UI checks: 'present' or 'absent' (default 'present')", default: "present" },
359
- stability_ms: { type: "number", description: "Stability window in ms that the condition must hold before returning success (default 700)", default: 700 },
360
- includeSnapshotOnFailure: { type: "boolean", description: "Whether to include a debug snapshot on timeout (default true)", default: true },
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 { type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId } = (args || {}) as any
684
- const res = await ToolsInteract.waitForUIHandler({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
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
+ })
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) })