mobile-debug-mcp 0.21.2 → 0.21.4
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 +202 -7
- package/dist/observe/android.js +88 -11
- package/dist/observe/index.js +32 -10
- package/dist/observe/ios.js +149 -12
- package/dist/server.js +30 -16
- package/dist/utils/cli/ios/run-ios-smoke.js +1 -1
- package/docs/CHANGELOG.md +7 -0
- package/docs/tools/observe.md +25 -10
- package/package.json +1 -1
- package/src/interact/index.ts +195 -7
- package/src/observe/android.ts +88 -15
- package/src/observe/index.ts +33 -12
- package/src/observe/ios.ts +151 -12
- package/src/server.ts +31 -16
- package/src/types.ts +13 -1
- package/src/utils/cli/ios/run-ios-smoke.ts +1 -1
- package/test/helpers/run-get-logs.ts +20 -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/device/get_logs.android.smoke.ts +35 -0
- package/test/observe/device/get_logs.ios.smoke.ts +33 -0
- package/test/observe/unit/get_logs.test.ts +58 -0
- package/test/observe/unit/ios-getlogs.test.ts +67 -0
- package/test/manage/device/run-install-kmp.ts +0 -18
package/dist/interact/index.js
CHANGED
|
@@ -215,9 +215,200 @@ export class ToolsInteract {
|
|
|
215
215
|
const scoreVal = Math.min(1, Number(bestScore.toFixed(3)));
|
|
216
216
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal };
|
|
217
217
|
}
|
|
218
|
-
static async waitForUIHandler({
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }) {
|
|
219
|
+
const overallStart = Date.now();
|
|
220
|
+
// Validate selector: require at least one of text, resource_id, or accessibility_id
|
|
221
|
+
if (!selector || (typeof selector === 'object' && Object.keys(selector).length === 0)) {
|
|
222
|
+
return { status: 'timeout', error: { code: 'INVALID_SELECTOR', message: 'At least one selector field must be provided (text, resource_id, or accessibility_id)' }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
|
|
223
|
+
}
|
|
224
|
+
const hasText = selector && typeof selector.text === 'string' && selector.text.trim().length > 0;
|
|
225
|
+
const hasResId = selector && typeof selector.resource_id === 'string' && selector.resource_id.trim().length > 0;
|
|
226
|
+
const hasAccId = selector && typeof selector.accessibility_id === 'string' && selector.accessibility_id.trim().length > 0;
|
|
227
|
+
if (!hasText && !hasResId && !hasAccId) {
|
|
228
|
+
return { status: 'timeout', error: { code: 'INVALID_SELECTOR', message: 'Selector must include at least one of: text, resource_id, accessibility_id' }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
|
|
229
|
+
}
|
|
230
|
+
// Validate condition
|
|
231
|
+
if (!['exists', 'not_exists', 'visible', 'clickable'].includes(condition)) {
|
|
232
|
+
return { status: 'timeout', error: { code: 'INVALID_CONDITION', message: `Unsupported condition: ${condition}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
|
|
233
|
+
}
|
|
234
|
+
// Platform check
|
|
235
|
+
if (platform && !['android', 'ios'].includes(platform)) {
|
|
236
|
+
return { status: 'timeout', error: { code: 'PLATFORM_NOT_SUPPORTED', message: `Unsupported platform: ${platform}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
|
|
237
|
+
}
|
|
238
|
+
const effectivePoll = Math.max(50, Math.min(poll_interval_ms || 300, 2000));
|
|
239
|
+
const maxAttempts = (retry && retry.max_attempts) ? Math.max(1, retry.max_attempts) : 1;
|
|
240
|
+
const backoff = (retry && retry.backoff_ms) ? Math.max(0, retry.backoff_ms) : 0;
|
|
241
|
+
let attempts = 0;
|
|
242
|
+
let totalPollCount = 0;
|
|
243
|
+
// Precompute normalized selector values and helpers (constant across polls)
|
|
244
|
+
const normalize = (s) => (s === null || s === undefined) ? '' : String(s).toLowerCase().trim();
|
|
245
|
+
const containsFlag = !!selector.contains;
|
|
246
|
+
const selText = normalize(selector.text);
|
|
247
|
+
const selRid = normalize(selector.resource_id);
|
|
248
|
+
const selAid = normalize(selector.accessibility_id);
|
|
249
|
+
try {
|
|
250
|
+
while (attempts < maxAttempts) {
|
|
251
|
+
attempts++;
|
|
252
|
+
const attemptStart = Date.now();
|
|
253
|
+
const deadline = attemptStart + (timeout_ms || 0);
|
|
254
|
+
while (Date.now() <= deadline) {
|
|
255
|
+
totalPollCount++;
|
|
256
|
+
try {
|
|
257
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
|
|
258
|
+
const elements = (tree && Array.isArray(tree.elements)) ? tree.elements : [];
|
|
259
|
+
const matches = [];
|
|
260
|
+
for (let i = 0; i < elements.length; i++) {
|
|
261
|
+
const el = elements[i];
|
|
262
|
+
let ok = true;
|
|
263
|
+
// text
|
|
264
|
+
if (selector.text !== undefined && selector.text !== null) {
|
|
265
|
+
const val = normalize(el.text || el.label || el.value || '');
|
|
266
|
+
if (containsFlag) {
|
|
267
|
+
if (!val.includes(selText))
|
|
268
|
+
ok = false;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
if (val !== selText)
|
|
272
|
+
ok = false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// resource_id
|
|
276
|
+
if (ok && selector.resource_id !== undefined && selector.resource_id !== null) {
|
|
277
|
+
const rid = normalize(el.resourceId || el.resourceID || el.id || '');
|
|
278
|
+
if (containsFlag) {
|
|
279
|
+
if (!rid.includes(selRid))
|
|
280
|
+
ok = false;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
if (rid !== selRid)
|
|
284
|
+
ok = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// accessibility_id
|
|
288
|
+
if (ok && selector.accessibility_id !== undefined && selector.accessibility_id !== null) {
|
|
289
|
+
const aid = normalize(el.contentDescription || el.contentDesc || el.accessibilityLabel || el.label || '');
|
|
290
|
+
if (containsFlag) {
|
|
291
|
+
if (!aid.includes(selAid))
|
|
292
|
+
ok = false;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
if (aid !== selAid)
|
|
296
|
+
ok = false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (ok)
|
|
300
|
+
matches.push({ el, idx: i });
|
|
301
|
+
}
|
|
302
|
+
// Evaluate condition
|
|
303
|
+
const matchedCount = matches.length;
|
|
304
|
+
const pickIndexProvided = (match && typeof match.index === 'number');
|
|
305
|
+
const pickIndex = pickIndexProvided ? Number(match.index) : 0;
|
|
306
|
+
let chosen = null;
|
|
307
|
+
if (matches.length === 0) {
|
|
308
|
+
chosen = null;
|
|
309
|
+
}
|
|
310
|
+
else if (pickIndexProvided) {
|
|
311
|
+
// If a specific index is requested but out of bounds, treat as not matched for this poll (deterministic)
|
|
312
|
+
if (pickIndex >= 0 && pickIndex < matches.length)
|
|
313
|
+
chosen = matches[pickIndex];
|
|
314
|
+
else
|
|
315
|
+
chosen = null;
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
chosen = matches[0];
|
|
319
|
+
}
|
|
320
|
+
let conditionMet = false;
|
|
321
|
+
if (condition === 'exists') {
|
|
322
|
+
// when an index is specified, existence requires that specific index be present
|
|
323
|
+
conditionMet = pickIndexProvided ? (chosen !== null) : (matchedCount >= 1);
|
|
324
|
+
}
|
|
325
|
+
else if (condition === 'not_exists') {
|
|
326
|
+
// when an index is specified, not_exists is true if that index is absent
|
|
327
|
+
conditionMet = pickIndexProvided ? (chosen === null) : (matchedCount === 0);
|
|
328
|
+
}
|
|
329
|
+
else if (condition === 'visible') {
|
|
330
|
+
if (chosen) {
|
|
331
|
+
const b = chosen.el.bounds;
|
|
332
|
+
const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1]);
|
|
333
|
+
conditionMet = visibleFlag;
|
|
334
|
+
}
|
|
335
|
+
else
|
|
336
|
+
conditionMet = false;
|
|
337
|
+
}
|
|
338
|
+
else if (condition === 'clickable') {
|
|
339
|
+
if (chosen) {
|
|
340
|
+
const b = chosen.el.bounds;
|
|
341
|
+
const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1]);
|
|
342
|
+
const enabled = !!chosen.el.enabled;
|
|
343
|
+
const clickable = !!chosen.el.clickable || !!chosen.el._interactable;
|
|
344
|
+
conditionMet = visibleFlag && enabled && clickable;
|
|
345
|
+
}
|
|
346
|
+
else
|
|
347
|
+
conditionMet = false;
|
|
348
|
+
}
|
|
349
|
+
if (conditionMet) {
|
|
350
|
+
const now = Date.now();
|
|
351
|
+
const latency_ms = now - overallStart;
|
|
352
|
+
// Build element output per spec
|
|
353
|
+
const outEl = chosen ? {
|
|
354
|
+
text: chosen.el.text ?? null,
|
|
355
|
+
resource_id: chosen.el.resourceId ?? chosen.el.resourceID ?? chosen.el.id ?? null,
|
|
356
|
+
accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
|
|
357
|
+
class: chosen.el.type ?? chosen.el.class ?? null,
|
|
358
|
+
bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
|
|
359
|
+
index: chosen.idx
|
|
360
|
+
} : null;
|
|
361
|
+
return {
|
|
362
|
+
status: 'success',
|
|
363
|
+
matched: matchedCount,
|
|
364
|
+
element: outEl,
|
|
365
|
+
metrics: { latency_ms, poll_count: totalPollCount, attempts }
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
// Non-fatal per-poll error; record and continue
|
|
371
|
+
console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e));
|
|
372
|
+
}
|
|
373
|
+
// Sleep until next poll
|
|
374
|
+
await new Promise(r => setTimeout(r, effectivePoll || 50));
|
|
375
|
+
}
|
|
376
|
+
// Attempt timed out; if more attempts allowed, backoff then retry
|
|
377
|
+
if (attempts < maxAttempts) {
|
|
378
|
+
if (backoff > 0)
|
|
379
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
// Final failure for this call
|
|
383
|
+
const elapsed = Date.now() - overallStart;
|
|
384
|
+
return {
|
|
385
|
+
status: 'timeout',
|
|
386
|
+
error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout` },
|
|
387
|
+
metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts }
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
const elapsed = Date.now() - overallStart;
|
|
393
|
+
return { status: 'timeout', error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) }, metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts } };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Helper: normalize various log objects into plain message strings for comparison
|
|
397
|
+
static _logsToMessages(logsArr) {
|
|
398
|
+
if (!Array.isArray(logsArr))
|
|
399
|
+
return [];
|
|
400
|
+
return logsArr.map((l) => {
|
|
401
|
+
if (typeof l === 'string')
|
|
402
|
+
return l;
|
|
403
|
+
if (l && (l.message || l.msg))
|
|
404
|
+
return l.message || l.msg;
|
|
405
|
+
try {
|
|
406
|
+
return JSON.stringify(l);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return String(l);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
221
412
|
}
|
|
222
413
|
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }) {
|
|
223
414
|
const start = Date.now();
|
|
@@ -275,7 +466,9 @@ export class ToolsInteract {
|
|
|
275
466
|
initialFingerprint = fpRes.fingerprint ?? null;
|
|
276
467
|
if (gl) {
|
|
277
468
|
const logsArr = Array.isArray(gl.logs) ? gl.logs : [];
|
|
278
|
-
|
|
469
|
+
// Normalize to last message string for baseline comparison
|
|
470
|
+
const msgs = ToolsInteract._logsToMessages(logsArr);
|
|
471
|
+
baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null;
|
|
279
472
|
}
|
|
280
473
|
}
|
|
281
474
|
catch (err) {
|
|
@@ -354,13 +547,15 @@ export class ToolsInteract {
|
|
|
354
547
|
}
|
|
355
548
|
const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 });
|
|
356
549
|
const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : [];
|
|
550
|
+
// Normalize to messages for comparison
|
|
551
|
+
const msgs = ToolsInteract._logsToMessages(logsArr);
|
|
357
552
|
let startIndex = 0;
|
|
358
553
|
if (baselineLastLine) {
|
|
359
|
-
const idx =
|
|
554
|
+
const idx = msgs.lastIndexOf(baselineLastLine);
|
|
360
555
|
startIndex = idx >= 0 ? idx + 1 : 0;
|
|
361
556
|
}
|
|
362
|
-
for (let i = startIndex; i <
|
|
363
|
-
const line =
|
|
557
|
+
for (let i = startIndex; i < msgs.length; i++) {
|
|
558
|
+
const line = msgs[i];
|
|
364
559
|
if (q && String(line).includes(q)) {
|
|
365
560
|
const now2 = Date.now();
|
|
366
561
|
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true };
|
package/dist/observe/android.js
CHANGED
|
@@ -80,27 +80,104 @@ export class AndroidObserve {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
async getLogs(
|
|
83
|
+
async getLogs(filters = {}) {
|
|
84
|
+
const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
|
|
84
85
|
const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
|
|
85
86
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
86
87
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
let
|
|
88
|
+
const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50;
|
|
89
|
+
// If appId provided, try to get pid to filter at source
|
|
90
|
+
let pidArg = null;
|
|
90
91
|
if (appId) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
try {
|
|
93
|
+
const pidOut = await execAdb(['shell', 'pidof', appId], deviceId).catch(() => '');
|
|
94
|
+
const pidTrim = (pidOut || '').trim();
|
|
95
|
+
if (pidTrim)
|
|
96
|
+
pidArg = pidTrim.split('\n')[0];
|
|
94
97
|
}
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
catch (err) {
|
|
99
|
+
// Log a warning so failures to detect PID are visible during debugging
|
|
100
|
+
try {
|
|
101
|
+
console.warn(`getLogs: pid detection failed for appId=${appId}:`, err instanceof Error ? err.message : String(err));
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
|
-
|
|
106
|
+
const args = ['logcat', '-d', '-v', 'threadtime'];
|
|
107
|
+
// Apply pid filter via --pid if available
|
|
108
|
+
if (pidArg)
|
|
109
|
+
args.push(`--pid=${pidArg}`);
|
|
110
|
+
else if (pid)
|
|
111
|
+
args.push(`--pid=${pid}`);
|
|
112
|
+
// Apply tag/level filter if provided
|
|
113
|
+
if (tag && level) {
|
|
114
|
+
// Map verbose/debug/info/warn/error to single-letter levels
|
|
115
|
+
const levelMap = { 'VERBOSE': 'V', 'DEBUG': 'D', 'INFO': 'I', 'WARN': 'W', 'ERROR': 'E' };
|
|
116
|
+
const L = (levelMap[(level || '').toUpperCase()] || 'V');
|
|
117
|
+
args.push(`${tag}:${L}`);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Default: show all levels
|
|
121
|
+
args.push('*:V');
|
|
122
|
+
}
|
|
123
|
+
// Use -t to limit lines (apply early)
|
|
124
|
+
args.push('-t', effectiveLimit.toString());
|
|
125
|
+
const stdout = await execAdb(args, deviceId);
|
|
126
|
+
const allLines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : [];
|
|
127
|
+
// Parse lines into structured entries
|
|
128
|
+
const parsed = allLines.map(l => {
|
|
129
|
+
const entry = parseLogLine(l);
|
|
130
|
+
// normalize level
|
|
131
|
+
const levelChar = (entry.level || '').toUpperCase();
|
|
132
|
+
const levelMapChar = { 'V': 'VERBOSE', 'D': 'DEBUG', 'I': 'INFO', 'W': 'WARN', 'E': 'ERROR' };
|
|
133
|
+
const normLevel = levelMapChar[levelChar] || (entry.level && String(entry.level).toUpperCase()) || 'INFO';
|
|
134
|
+
const iso = entry._iso || (() => {
|
|
135
|
+
const d = new Date(entry.timestamp || '');
|
|
136
|
+
if (!isNaN(d.getTime()))
|
|
137
|
+
return d.toISOString();
|
|
138
|
+
return null;
|
|
139
|
+
})();
|
|
140
|
+
let pidNum = null;
|
|
141
|
+
if (entry.pid)
|
|
142
|
+
pidNum = Number(entry.pid);
|
|
143
|
+
else if (pidArg)
|
|
144
|
+
pidNum = Number(pidArg);
|
|
145
|
+
const pidVal = (pidNum !== null && !isNaN(pidNum)) ? pidNum : null;
|
|
146
|
+
return { timestamp: iso, level: normLevel, tag: entry.tag || '', pid: pidVal, message: entry.message || '' };
|
|
147
|
+
});
|
|
148
|
+
// Apply client-side filters: contains, since_seconds
|
|
149
|
+
let filtered = parsed;
|
|
150
|
+
if (contains)
|
|
151
|
+
filtered = filtered.filter(e => e.message && e.message.includes(contains));
|
|
152
|
+
if (since_seconds) {
|
|
153
|
+
const sinceMs = Date.now() - (since_seconds * 1000);
|
|
154
|
+
filtered = filtered.filter(e => {
|
|
155
|
+
if (!e.timestamp)
|
|
156
|
+
return false;
|
|
157
|
+
const t = new Date(e.timestamp).getTime();
|
|
158
|
+
return t >= sinceMs;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// If appId provided and no pidArg, try to filter by appId substring in message/tag
|
|
162
|
+
if (appId && !pidArg) {
|
|
163
|
+
const matched = filtered.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)));
|
|
164
|
+
if (matched.length > 0)
|
|
165
|
+
filtered = matched;
|
|
166
|
+
}
|
|
167
|
+
// Ensure ordering oldest -> newest (by timestamp when available)
|
|
168
|
+
filtered.sort((a, b) => {
|
|
169
|
+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
|
170
|
+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
|
171
|
+
return ta - tb;
|
|
172
|
+
});
|
|
173
|
+
const limited = filtered.slice(-Math.max(0, effectiveLimit));
|
|
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 };
|
|
100
177
|
}
|
|
101
178
|
catch (e) {
|
|
102
179
|
console.error("Error fetching logs:", e);
|
|
103
|
-
return { device: deviceInfo, logs: [], logCount: 0 };
|
|
180
|
+
return { device: deviceInfo, logs: [], logCount: 0, source: 'broad', meta: { error: e instanceof Error ? e.message : String(e) } };
|
|
104
181
|
}
|
|
105
182
|
}
|
|
106
183
|
async captureScreen(deviceId) {
|
package/dist/observe/index.js
CHANGED
|
@@ -33,19 +33,30 @@ export class ToolsObserve {
|
|
|
33
33
|
// getCurrentScreen is Android-specific
|
|
34
34
|
return await observe.getCurrentScreen(resolved.id);
|
|
35
35
|
}
|
|
36
|
-
static async getLogsHandler({ platform, appId, deviceId, lines }) {
|
|
36
|
+
static async getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines }) {
|
|
37
37
|
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId);
|
|
38
|
+
const filters = { appId, deviceId: resolved.id, pid, tag, level, contains, since_seconds, limit: limit ?? lines };
|
|
39
|
+
// Validate filters
|
|
40
|
+
if (level && !['VERBOSE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].includes(level.toString().toUpperCase())) {
|
|
41
|
+
return { device: resolved, logs: [], crashLines: [], logCount: 0, error: { code: 'INVALID_FILTER', message: `Unsupported level filter: ${level}` } };
|
|
42
|
+
}
|
|
38
43
|
if (observe instanceof AndroidObserve) {
|
|
39
|
-
const response = await observe.getLogs(
|
|
44
|
+
const response = await observe.getLogs(filters);
|
|
40
45
|
const logs = Array.isArray(response.logs) ? response.logs : [];
|
|
41
|
-
const crashLines = logs.filter(
|
|
42
|
-
|
|
46
|
+
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
|
|
47
|
+
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
|
|
48
|
+
if (anyFilterApplied && logs.length === 0)
|
|
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 };
|
|
43
51
|
}
|
|
44
52
|
else {
|
|
45
|
-
const resp = await observe.getLogs(
|
|
53
|
+
const resp = await observe.getLogs(filters);
|
|
46
54
|
const logs = Array.isArray(resp.logs) ? resp.logs : [];
|
|
47
|
-
const crashLines = logs.filter(
|
|
48
|
-
|
|
55
|
+
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
|
|
56
|
+
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
|
|
57
|
+
if (anyFilterApplied && logs.length === 0)
|
|
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 };
|
|
49
60
|
}
|
|
50
61
|
}
|
|
51
62
|
static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
|
|
@@ -154,9 +165,20 @@ export class ToolsObserve {
|
|
|
154
165
|
if (!entries || entries.length === 0) {
|
|
155
166
|
const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines });
|
|
156
167
|
const raw = (gl && gl.logs) ? gl.logs : [];
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
// raw may be structured entries or strings
|
|
169
|
+
entries = raw.slice(-Math.max(0, logLines)).map(item => {
|
|
170
|
+
if (!item)
|
|
171
|
+
return { timestamp: null, level: 'INFO', message: '' };
|
|
172
|
+
if (typeof item === 'string') {
|
|
173
|
+
const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(item) ? 'ERROR' : /\b(WARN| W )\b/i.test(item) ? 'WARN' : 'INFO';
|
|
174
|
+
return { timestamp: null, level, message: item };
|
|
175
|
+
}
|
|
176
|
+
const msg = item.message || item.msg || JSON.stringify(item);
|
|
177
|
+
const levelRaw = item.level || item.levelName || item._level || '';
|
|
178
|
+
const level = (levelRaw && String(levelRaw)).toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO');
|
|
179
|
+
const ts = item.timestamp || item._iso || null;
|
|
180
|
+
const tsNum = (ts && typeof ts === 'string') ? (isNaN(new Date(ts).getTime()) ? null : new Date(ts).getTime()) : (typeof ts === 'number' ? ts : null);
|
|
181
|
+
return { timestamp: tsNum, level, message: msg };
|
|
160
182
|
});
|
|
161
183
|
}
|
|
162
184
|
else {
|
package/dist/observe/ios.js
CHANGED
|
@@ -88,20 +88,156 @@ export class iOSObserve {
|
|
|
88
88
|
async getDeviceMetadata(deviceId = "booted") {
|
|
89
89
|
return getIOSDeviceMetadata(deviceId);
|
|
90
90
|
}
|
|
91
|
-
async getLogs(
|
|
92
|
-
const
|
|
91
|
+
async getLogs(filters = {}) {
|
|
92
|
+
const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters;
|
|
93
|
+
const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog'];
|
|
94
|
+
// Default to last N seconds if no since_seconds provided; limit lines handled after parsing
|
|
95
|
+
const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50;
|
|
96
|
+
if (since_seconds) {
|
|
97
|
+
// log show accepts --last <time>
|
|
98
|
+
args.push('--last', `${since_seconds}s`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
// default to last 60s to keep quick
|
|
102
|
+
args.push('--last', '60s');
|
|
103
|
+
}
|
|
104
|
+
let processNameUsed = undefined;
|
|
93
105
|
if (appId) {
|
|
94
106
|
validateBundleId(appId);
|
|
95
|
-
|
|
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}"`);
|
|
113
|
+
}
|
|
114
|
+
else if (tag) {
|
|
115
|
+
// predicate by subsystem/category
|
|
116
|
+
args.push('--predicate', `subsystem contains "${tag}"`);
|
|
117
|
+
}
|
|
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;
|
|
139
|
+
const result = await execCommand(args, deviceId);
|
|
140
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
141
|
+
const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : [];
|
|
142
|
+
// Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
|
|
143
|
+
const parsed = rawLines.map(line => {
|
|
144
|
+
// Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
|
|
145
|
+
// Simpler approach: try to extract ISO timestamp at start
|
|
146
|
+
let ts = null;
|
|
147
|
+
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/);
|
|
148
|
+
if (tsMatch) {
|
|
149
|
+
const d = new Date(tsMatch[1]);
|
|
150
|
+
if (!isNaN(d.getTime()))
|
|
151
|
+
ts = d.toISOString();
|
|
152
|
+
}
|
|
153
|
+
// level mapping
|
|
154
|
+
let lvl = 'INFO';
|
|
155
|
+
const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i);
|
|
156
|
+
if (lvlMatch) {
|
|
157
|
+
const map = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' };
|
|
158
|
+
lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO';
|
|
159
|
+
}
|
|
160
|
+
// subsystem/category -> tag
|
|
161
|
+
let tagVal = '';
|
|
162
|
+
const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/);
|
|
163
|
+
if (tagMatch)
|
|
164
|
+
tagVal = tagMatch[1];
|
|
165
|
+
// pid extraction
|
|
166
|
+
let pidNum = null;
|
|
167
|
+
const pidMatch = line.match(/\[(\d+):\d+\]/);
|
|
168
|
+
if (pidMatch)
|
|
169
|
+
pidNum = Number(pidMatch[1]);
|
|
170
|
+
// message: extract robustly after the subsystem/category token when available
|
|
171
|
+
let message = line;
|
|
172
|
+
if (tagMatch) {
|
|
173
|
+
// tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
|
|
174
|
+
const marker = tagMatch[0];
|
|
175
|
+
const idx = line.indexOf(marker);
|
|
176
|
+
if (idx !== -1) {
|
|
177
|
+
message = line.slice(idx + marker.length).trim();
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
// fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
|
|
181
|
+
const afterPidMatch = line.match(/\]\s+/);
|
|
182
|
+
if (afterPidMatch) {
|
|
183
|
+
const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length;
|
|
184
|
+
message = line.slice(afterPidIdx).trim();
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
// remove leading level tokens like <Debug> and keep remainder
|
|
188
|
+
message = line.replace(/^.*?<.*?>\s*/, '').trim();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
// No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
|
|
194
|
+
message = line.replace(/^.*?<.*?>\s*/, '').trim();
|
|
195
|
+
}
|
|
196
|
+
return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message };
|
|
197
|
+
});
|
|
198
|
+
// Apply contains filter
|
|
199
|
+
let filtered = parsed;
|
|
200
|
+
if (contains)
|
|
201
|
+
filtered = filtered.filter(e => e.message && e.message.includes(contains));
|
|
202
|
+
// Apply since_seconds already applied by log show, but double-check timestamps
|
|
203
|
+
if (since_seconds) {
|
|
204
|
+
const sinceMs = Date.now() - (since_seconds * 1000);
|
|
205
|
+
filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs));
|
|
206
|
+
}
|
|
207
|
+
// level filter
|
|
208
|
+
if (level) {
|
|
209
|
+
const L = level.toUpperCase();
|
|
210
|
+
filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L);
|
|
211
|
+
}
|
|
212
|
+
// tag filter
|
|
213
|
+
if (tag)
|
|
214
|
+
filtered = filtered.filter(e => e.tag && e.tag.includes(tag));
|
|
215
|
+
// pid filter (use detected/effective pid if available)
|
|
216
|
+
const pidToFilter = effectivePid;
|
|
217
|
+
if (pidToFilter)
|
|
218
|
+
filtered = filtered.filter(e => e.pid === pidToFilter);
|
|
219
|
+
// If appId present but no predicate returned lines, try substring match
|
|
220
|
+
if (appId && filtered.length === 0) {
|
|
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)));
|
|
222
|
+
if (matched.length > 0)
|
|
223
|
+
filtered = matched;
|
|
224
|
+
}
|
|
225
|
+
// Order oldest -> newest
|
|
226
|
+
filtered.sort((a, b) => {
|
|
227
|
+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
|
228
|
+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
|
229
|
+
return ta - tb;
|
|
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 };
|
|
233
|
+
const limited = filtered.slice(-Math.max(0, effectiveLimit));
|
|
234
|
+
return { device, logs: limited, logCount: limited.length, source, meta };
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
console.error('iOS getLogs failed:', err);
|
|
238
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
239
|
+
return { device, logs: [], logCount: 0, source: 'broad', meta: { error: err instanceof Error ? err.message : String(err) } };
|
|
96
240
|
}
|
|
97
|
-
const result = await execCommand(args, deviceId);
|
|
98
|
-
const device = await getIOSDeviceMetadata(deviceId);
|
|
99
|
-
const logs = result.output ? result.output.split('\n') : [];
|
|
100
|
-
return {
|
|
101
|
-
device,
|
|
102
|
-
logs,
|
|
103
|
-
logCount: logs.length,
|
|
104
|
-
};
|
|
105
241
|
}
|
|
106
242
|
async captureScreenshot(deviceId = "booted") {
|
|
107
243
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
@@ -275,7 +411,8 @@ export class iOSObserve {
|
|
|
275
411
|
}
|
|
276
412
|
async startLogStream(bundleId, deviceId = 'booted', sessionId = 'default') {
|
|
277
413
|
try {
|
|
278
|
-
const
|
|
414
|
+
const simple = bundleId.split('.').pop() || bundleId;
|
|
415
|
+
const predicate = `process == "${simple}" or process == "${bundleId}" or subsystem contains "${bundleId}"`;
|
|
279
416
|
if (iosActiveLogStreams.has(sessionId)) {
|
|
280
417
|
try {
|
|
281
418
|
iosActiveLogStreams.get(sessionId).proc.kill();
|