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.
@@ -121,20 +121,158 @@ export class iOSObserve {
121
121
  return getIOSDeviceMetadata(deviceId);
122
122
  }
123
123
 
124
- async getLogs(appId?: string, deviceId: string = "booted"): Promise<GetLogsResponse> {
125
- const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m']
124
+ async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
125
+ const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters
126
+ const args: string[] = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog']
127
+
128
+ // Default to last N seconds if no since_seconds provided; limit lines handled after parsing
129
+ const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50
130
+
131
+ if (since_seconds) {
132
+ // log show accepts --last <time>
133
+ args.push('--last', `${since_seconds}s`)
134
+ } else {
135
+ // default to last 60s to keep quick
136
+ args.push('--last', '60s')
137
+ }
138
+
139
+ let processNameUsed: string | undefined = undefined
126
140
  if (appId) {
127
141
  validateBundleId(appId)
128
- 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}"`)
148
+ } else if (tag) {
149
+ // predicate by subsystem/category
150
+ args.push('--predicate', `subsystem contains "${tag}"`)
129
151
  }
130
-
131
- const result = await execCommand(args, deviceId)
132
- const device = await getIOSDeviceMetadata(deviceId)
133
- const logs = result.output ? result.output.split('\n') : []
134
- return {
135
- device,
136
- logs,
137
- logCount: logs.length,
152
+
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
172
+ const result = await execCommand(args, deviceId)
173
+ const device = await getIOSDeviceMetadata(deviceId)
174
+ const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
175
+
176
+ // Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
177
+ const parsed = rawLines.map(line => {
178
+ // Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
179
+ // Simpler approach: try to extract ISO timestamp at start
180
+ let ts: string | null = null
181
+ const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)
182
+ if (tsMatch) {
183
+ const d = new Date(tsMatch[1])
184
+ if (!isNaN(d.getTime())) ts = d.toISOString()
185
+ }
186
+
187
+ // level mapping
188
+ let lvl = 'INFO'
189
+ const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i)
190
+ if (lvlMatch) {
191
+ const map: Record<string, string> = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' }
192
+ lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO'
193
+ }
194
+
195
+ // subsystem/category -> tag
196
+ let tagVal = ''
197
+ const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/)
198
+ if (tagMatch) tagVal = tagMatch[1]
199
+
200
+ // pid extraction
201
+ let pidNum: number | null = null
202
+ const pidMatch = line.match(/\[(\d+):\d+\]/)
203
+ if (pidMatch) pidNum = Number(pidMatch[1])
204
+
205
+ // message: extract robustly after the subsystem/category token when available
206
+ let message = line
207
+ if (tagMatch) {
208
+ // tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
209
+ const marker = tagMatch[0]
210
+ const idx = line.indexOf(marker)
211
+ if (idx !== -1) {
212
+ message = line.slice(idx + marker.length).trim()
213
+ } else {
214
+ // fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
215
+ const afterPidMatch = line.match(/\]\s+/)
216
+ if (afterPidMatch) {
217
+ const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length
218
+ message = line.slice(afterPidIdx).trim()
219
+ } else {
220
+ // remove leading level tokens like <Debug> and keep remainder
221
+ message = line.replace(/^.*?<.*?>\s*/,'').trim()
222
+ }
223
+ }
224
+ } else {
225
+ // No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
226
+ message = line.replace(/^.*?<.*?>\s*/,'').trim()
227
+ }
228
+
229
+ return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message }
230
+ })
231
+
232
+ // Apply contains filter
233
+ let filtered = parsed
234
+ if (contains) filtered = filtered.filter(e => e.message && e.message.includes(contains))
235
+
236
+ // Apply since_seconds already applied by log show, but double-check timestamps
237
+ if (since_seconds) {
238
+ const sinceMs = Date.now() - (since_seconds * 1000)
239
+ filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs))
240
+ }
241
+
242
+ // level filter
243
+ if (level) {
244
+ const L = level.toUpperCase()
245
+ filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L)
246
+ }
247
+
248
+ // tag filter
249
+ if (tag) filtered = filtered.filter(e => e.tag && e.tag.includes(tag))
250
+
251
+ // pid filter (use detected/effective pid if available)
252
+ const pidToFilter = effectivePid
253
+ if (pidToFilter) filtered = filtered.filter(e => e.pid === pidToFilter)
254
+
255
+ // If appId present but no predicate returned lines, try substring match
256
+ if (appId && filtered.length === 0) {
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)))
258
+ if (matched.length > 0) filtered = matched
259
+ }
260
+
261
+ // Order oldest -> newest
262
+ filtered.sort((a,b) => {
263
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0
264
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0
265
+ return ta - tb
266
+ })
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 }
270
+ const limited = filtered.slice(-Math.max(0, effectiveLimit))
271
+ return { device, logs: limited, logCount: limited.length, source, meta }
272
+ } catch (err) {
273
+ console.error('iOS getLogs failed:', err)
274
+ const device = await getIOSDeviceMetadata(deviceId)
275
+ return { device, logs: [], logCount: 0, source: 'broad', meta: { error: err instanceof Error ? err.message : String(err) } }
138
276
  }
139
277
  }
140
278
 
@@ -327,7 +465,8 @@ export class iOSObserve {
327
465
 
328
466
  async startLogStream(bundleId: string, deviceId: string = 'booted', sessionId: string = 'default') : Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
329
467
  try {
330
- 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}"`
331
470
 
332
471
  if (iosActiveLogStreams.has(sessionId)) {
333
472
  try { iosActiveLogStreams.get(sessionId)!.proc.kill() } catch {}
package/src/server.ts CHANGED
@@ -171,7 +171,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
171
171
 
172
172
  {
173
173
  name: "get_logs",
174
- description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
174
+ description: "Get recent logs from Android or iOS simulator. Returns device metadata and structured logs suitable for AI consumption.",
175
175
  inputSchema: {
176
176
  type: "object",
177
177
  properties: {
@@ -187,9 +187,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
187
187
  type: "string",
188
188
  description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
189
189
  },
190
+ pid: { type: "number", description: "Filter by process id" },
191
+ tag: { type: "string", description: "Filter by tag (Android) or subsystem/category (iOS)" },
192
+ level: { type: "string", description: "Log level filter (VERBOSE, DEBUG, INFO, WARN, ERROR)" },
193
+ contains: { type: "string", description: "Substring to match in log message" },
194
+ since_seconds: { type: "number", description: "Only return logs from the last N seconds" },
195
+ limit: { type: "number", description: "Override default number of returned lines" },
190
196
  lines: {
191
197
  type: "number",
192
- description: "Number of log lines (android only)"
198
+ description: "Legacy - number of log lines (android only)"
193
199
  }
194
200
  },
195
201
  required: ["platform"]
@@ -341,17 +347,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
341
347
  },
342
348
  {
343
349
  name: "wait_for_ui",
344
- 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.",
345
351
  inputSchema: {
346
352
  type: "object",
347
353
  properties: {
348
- type: { type: "string", enum: ["ui","log","screen","idle"], description: "Condition type to observe", default: "ui" },
349
- query: { type: "string", description: "Optional query string for ui/log/screen types" },
350
- timeoutMs: { type: "number", description: "Timeout in ms to wait for condition (default 30000)", default: 30000 },
351
- pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300, clamped to 250-500)", default: 300 },
352
- match: { type: "string", enum: ["present","absent"], description: "Match mode for UI checks: 'present' or 'absent' (default 'present')", default: "present" },
353
- stability_ms: { type: "number", description: "Stability window in ms that the condition must hold before returning success (default 700)", default: 700 },
354
- 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 } } },
355
368
  platform: { type: "string", enum: ["android","ios"], description: "Optional platform override" },
356
369
  deviceId: { type: "string", description: "Optional device serial/udid" }
357
370
  }
@@ -359,6 +372,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
359
372
  },
360
373
 
361
374
 
375
+
362
376
  {
363
377
  name: "find_element",
364
378
  description: "Find a UI element by semantic query (text, content-desc, resource-id, class). Returns best match.",
@@ -603,12 +617,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
603
617
 
604
618
 
605
619
  if (name === "get_logs") {
606
- const { platform, appId, deviceId, lines } = args as any
607
- const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines })
620
+ const { platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines } = args as any
621
+ const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines })
622
+ const filtered = !!(pid || tag || level || contains || since_seconds || appId)
608
623
  return {
609
624
  content: [
610
- { type: 'text', text: JSON.stringify({ device: res.device, result: { lines: res.logs.length, crashLines: res.crashLines.length > 0 ? res.crashLines : undefined } }, null, 2) },
611
- { type: 'text', text: (res.logs || []).join('\n') }
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) },
626
+ { type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
612
627
  ]
613
628
  }
614
629
  }
@@ -673,8 +688,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
673
688
 
674
689
 
675
690
  if (name === "wait_for_ui") {
676
- const { type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId } = (args || {}) as any
677
- 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 })
678
693
  return wrapResponse(res)
679
694
  }
680
695
 
package/src/types.ts CHANGED
@@ -36,10 +36,22 @@ export interface ResetAppDataResponse {
36
36
  diagnostics?: any;
37
37
  }
38
38
 
39
+ export interface StructuredLogEntry {
40
+ timestamp: string | null; // ISO string
41
+ level: string; // VERBOSE, DEBUG, INFO, WARN, ERROR
42
+ tag: string;
43
+ pid: number | null;
44
+ message: string;
45
+ }
46
+
39
47
  export interface GetLogsResponse {
40
48
  device: DeviceInfo;
41
- logs: string[];
49
+ logs: StructuredLogEntry[];
42
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>;
43
55
  }
44
56
 
45
57
  export interface GetCrashResponse {
@@ -17,7 +17,7 @@ async function main() {
17
17
  console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0)
18
18
 
19
19
  console.log('[3] getLogs')
20
- const logs = await obs.getLogs(appId, undefined);
20
+ const logs = await obs.getLogs({ appId, deviceId });
21
21
  console.log('logs count:', logs.logCount)
22
22
 
23
23
  console.log('[4] terminateApp')
@@ -0,0 +1,20 @@
1
+ import { ToolsObserve } from '../../src/observe/index.js'
2
+ import minimist from 'minimist'
3
+
4
+ async function main() {
5
+ const args = minimist(process.argv.slice(2))
6
+ const platform = args.platform || args.p || 'android'
7
+ const id = args.id || args.device || args.deviceId || 'default'
8
+ const limit = typeof args.limit === 'number' ? args.limit : (typeof args.lines === 'number' ? args.lines : 50)
9
+
10
+ try {
11
+ const res = await ToolsObserve.getLogsHandler({ platform, id, limit })
12
+ console.log(JSON.stringify(res))
13
+ process.exit(0)
14
+ } catch (err: any) {
15
+ console.error(JSON.stringify({ error: { message: err.message || String(err) } }))
16
+ process.exit(2)
17
+ }
18
+ }
19
+
20
+ main()
@@ -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,35 @@
1
+ import fs from 'fs'
2
+ import { execSync } from 'child_process'
3
+
4
+ function log(msg: string) { console.log(msg) }
5
+
6
+ if (process.env.SKIP_DEVICE_TESTS === '1') {
7
+ log('SKIP_DEVICE_TESTS=1 detected - skipping android device smoke test')
8
+ process.exit(0)
9
+ }
10
+
11
+ // Ensure helper script exists
12
+ const helperScript = 'test/helpers/run-get-logs.ts'
13
+ if (!fs.existsSync(helperScript)) {
14
+ console.error(`Missing ${helperScript}. Run 'npm run build' first or ensure the helper exists.`)
15
+ process.exit(1)
16
+ }
17
+
18
+ try {
19
+ // Run the helper smoke script for android
20
+ const cmd = `tsx ${helperScript} --platform android --id default --limit 20`
21
+ const out = execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 30000 })
22
+ const parsed = JSON.parse(out)
23
+
24
+ if (!parsed || !Array.isArray(parsed.logs)) throw new Error('Output missing logs array')
25
+ const count = parsed.count ?? parsed.logs.length
26
+ if (count !== parsed.logs.length) throw new Error('count mismatch')
27
+ if (parsed.logs.some((e: any) => !e.timestamp || !e.level || typeof e.message !== 'string')) throw new Error('log entry missing fields')
28
+
29
+ log('Android device smoke test: PASS')
30
+ process.exit(0)
31
+ } catch (err: any) {
32
+ console.error('Android device smoke test: FAIL')
33
+ console.error(err && err.message ? err.message : err)
34
+ process.exit(2)
35
+ }
@@ -0,0 +1,33 @@
1
+ import fs from 'fs'
2
+ import { execSync } from 'child_process'
3
+
4
+ function log(msg: string) { console.log(msg) }
5
+
6
+ if (process.env.SKIP_DEVICE_TESTS === '1') {
7
+ log('SKIP_DEVICE_TESTS=1 detected - skipping ios device smoke test')
8
+ process.exit(0)
9
+ }
10
+
11
+ const helperScript = 'test/helpers/run-get-logs.ts'
12
+ if (!fs.existsSync(helperScript)) {
13
+ console.error(`Missing ${helperScript}. Run 'npm run build' first or ensure the helper exists.`)
14
+ process.exit(1)
15
+ }
16
+
17
+ try {
18
+ const cmd = `tsx ${helperScript} --platform ios --id booted --limit 20`
19
+ const out = execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 30000 })
20
+ const parsed = JSON.parse(out)
21
+
22
+ if (!parsed || !Array.isArray(parsed.logs)) throw new Error('Output missing logs array')
23
+ const count = parsed.count ?? parsed.logs.length
24
+ if (count !== parsed.logs.length) throw new Error('count mismatch')
25
+ if (parsed.logs.some((e: any) => !e.timestamp || !e.level || typeof e.message !== 'string')) throw new Error('log entry missing fields')
26
+
27
+ log('iOS device smoke test: PASS')
28
+ process.exit(0)
29
+ } catch (err: any) {
30
+ console.error('iOS device smoke test: FAIL')
31
+ console.error(err && err.message ? err.message : err)
32
+ process.exit(2)
33
+ }
@@ -0,0 +1,58 @@
1
+ import { StructuredLogEntry } from '../../../src/types.js'
2
+
3
+ function assert(cond: boolean, msg?: string) { if (!cond) throw new Error(msg || 'Assertion failed') }
4
+
5
+ function applyFilters(entries: StructuredLogEntry[], opts: { contains?: string, level?: string, tag?: string, pid?: number, since_seconds?: number, limit?: number }) {
6
+ let filtered = entries.slice()
7
+ if (opts.contains) filtered = filtered.filter(e => e.message && e.message.includes(opts.contains!))
8
+ if (opts.since_seconds) {
9
+ const sinceMs = Date.now() - (opts.since_seconds * 1000)
10
+ filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs))
11
+ }
12
+ if (opts.level) filtered = filtered.filter(e => e.level && e.level.toUpperCase() === opts.level!.toUpperCase())
13
+ if (opts.tag) filtered = filtered.filter(e => e.tag && e.tag.includes(opts.tag!))
14
+ if (typeof opts.pid === 'number') filtered = filtered.filter(e => e.pid === opts.pid)
15
+ // oldest -> newest
16
+ filtered.sort((a,b) => (a.timestamp? new Date(a.timestamp).getTime():0) - (b.timestamp? new Date(b.timestamp).getTime():0))
17
+ const lim = typeof opts.limit === 'number' && opts.limit > 0 ? opts.limit : 50
18
+ return filtered.slice(-Math.max(0, lim))
19
+ }
20
+
21
+ function run() {
22
+ const now = Date.now()
23
+ const entries: StructuredLogEntry[] = [
24
+ { timestamp: new Date(now - 60000).toISOString(), level: 'INFO', tag: 'A', pid: 123, message: 'startup complete' },
25
+ { timestamp: new Date(now - 45000).toISOString(), level: 'WARN', tag: 'B', pid: 124, message: 'slow response' },
26
+ { timestamp: new Date(now - 30000).toISOString(), level: 'ERROR', tag: 'A', pid: 123, message: 'Unhandled exception' },
27
+ { timestamp: new Date(now - 15000).toISOString(), level: 'DEBUG', tag: 'C', pid: 125, message: 'debug info' },
28
+ { timestamp: new Date(now - 5000).toISOString(), level: 'INFO', tag: 'A', pid: 123, message: 'user action happened' }
29
+ ]
30
+
31
+ // contains filter
32
+ const c1 = applyFilters(entries, { contains: 'user' })
33
+ assert(c1.length === 1 && c1[0].message.includes('user'), 'contains filter failed')
34
+
35
+ // level filter
36
+ const e1 = applyFilters(entries, { level: 'ERROR' })
37
+ assert(e1.length === 1 && e1[0].level === 'ERROR', 'level filter failed')
38
+
39
+ // tag filter
40
+ const t1 = applyFilters(entries, { tag: 'A' })
41
+ assert(t1.length === 3, 'tag filter failed')
42
+
43
+ // pid filter
44
+ const p1 = applyFilters(entries, { pid: 123 })
45
+ assert(p1.length === 3, 'pid filter failed')
46
+
47
+ // since_seconds filter (last 20s) should include last two entries
48
+ const s1 = applyFilters(entries, { since_seconds: 20 })
49
+ if (s1.length !== 2) throw new Error('since_seconds filter expected 2 entries, got ' + s1.length)
50
+
51
+ // limit
52
+ const l1 = applyFilters(entries, { limit: 2 })
53
+ assert(l1.length === 2 && l1[0].timestamp <= l1[1].timestamp, 'limit or ordering failed')
54
+
55
+ console.log('get_logs unit tests passed')
56
+ }
57
+
58
+ run()
@@ -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
+ })
@@ -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) })