mobile-debug-mcp 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +18 -443
  2. package/dist/android/interact.js +96 -1
  3. package/dist/android/utils.js +404 -12
  4. package/dist/ios/interact.js +105 -0
  5. package/dist/ios/utils.js +154 -0
  6. package/dist/resolve-device.js +70 -0
  7. package/dist/server.js +126 -194
  8. package/dist/tools/app.js +45 -0
  9. package/dist/tools/devices.js +5 -0
  10. package/dist/tools/install.js +47 -0
  11. package/dist/tools/logs.js +62 -0
  12. package/dist/tools/screenshot.js +17 -0
  13. package/dist/tools/ui.js +57 -0
  14. package/docs/CHANGELOG.md +19 -0
  15. package/docs/TOOLS.md +272 -0
  16. package/package.json +6 -2
  17. package/src/android/interact.ts +100 -1
  18. package/src/android/utils.ts +395 -10
  19. package/src/ios/interact.ts +102 -0
  20. package/src/ios/utils.ts +157 -0
  21. package/src/resolve-device.ts +80 -0
  22. package/src/server.ts +149 -276
  23. package/src/tools/app.ts +46 -0
  24. package/src/tools/devices.ts +6 -0
  25. package/src/tools/install.ts +43 -0
  26. package/src/tools/logs.ts +62 -0
  27. package/src/tools/screenshot.ts +18 -0
  28. package/src/tools/ui.ts +62 -0
  29. package/src/types.ts +7 -0
  30. package/test/integration/index.ts +8 -0
  31. package/test/integration/install.integration.ts +64 -0
  32. package/test/integration/logstream-real.ts +35 -0
  33. package/test/integration/run-install-android.ts +21 -0
  34. package/test/integration/run-install-ios.ts +21 -0
  35. package/test/integration/run-real-test.ts +19 -0
  36. package/{smoke-test.ts → test/integration/smoke-test.ts} +17 -25
  37. package/test/integration/test-dist.mjs +41 -0
  38. package/test/integration/test-dist.ts +41 -0
  39. package/{test-ui-tree.ts → test/integration/test-ui-tree.ts} +2 -2
  40. package/test/integration/wait_for_element_real.ts +80 -0
  41. package/test/unit/index.ts +7 -0
  42. package/test/unit/install.test.ts +82 -0
  43. package/test/unit/logparse.test.ts +41 -0
  44. package/test/unit/logstream.test.ts +46 -0
  45. package/test/unit/wait_for_element_mock.ts +104 -0
  46. package/tsconfig.json +1 -1
  47. package/smoke-test.js +0 -102
  48. package/test/run-real-test.js +0 -24
  49. package/test/wait_for_element_mock.js +0 -113
  50. package/test/wait_for_element_real.js +0 -67
  51. package/test-ui-tree.js +0 -68
@@ -1,7 +1,75 @@
1
- import { spawn } from "child_process"
1
+ import { spawn, execSync } from 'child_process'
2
2
  import { DeviceInfo } from "../types.js"
3
+ import { existsSync, createWriteStream, promises as fsPromises } from 'fs'
4
+ import path from 'path'
3
5
 
4
- export const ADB = process.env.ADB_PATH || "adb"
6
+ export const ADB = process.env.ADB_PATH || 'adb'
7
+
8
+ export async function detectJavaHome(): Promise<string | undefined> {
9
+ try {
10
+ // If JAVA_HOME is set, validate it's Java 17
11
+ if (process.env.JAVA_HOME) {
12
+ try {
13
+ const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java')
14
+ const v = execSync(`"${javaBin}" -version`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString()
15
+ if (/\b17\b/.test(v) || /17\./.test(v)) return process.env.JAVA_HOME
16
+ console.debug('[android] Existing JAVA_HOME does not appear to be Java 17, will search for JDK17')
17
+ } catch (e) {
18
+ console.debug('[android] Failed to validate existing JAVA_HOME, searching for JDK17')
19
+ }
20
+ }
21
+
22
+ // macOS explicit path
23
+ const explicit = '/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home'
24
+ if (existsSync(explicit)) return explicit
25
+
26
+ // Android Studio JBR candidates
27
+ const jbrCandidates = [
28
+ '/Applications/Android Studio.app/Contents/jbr',
29
+ '/Applications/Android Studio Preview.app/Contents/jbr',
30
+ '/Applications/Android Studio Preview 2022.3.app/Contents/jbr',
31
+ '/Applications/Android Studio Preview 2023.1.app/Contents/jbr'
32
+ ]
33
+ for (const p of jbrCandidates) {
34
+ const javaBin = path.join(p, 'bin', 'java')
35
+ if (existsSync(javaBin)) {
36
+ try {
37
+ const v = execSync(`"${javaBin}" -version`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString()
38
+ if (/\b17\b/.test(v) || /17\./.test(v)) return p
39
+ } catch {}
40
+ }
41
+ }
42
+
43
+ // macOS /usr/libexec/java_home
44
+ try {
45
+ const out = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
46
+ if (out) return out
47
+ } catch {}
48
+
49
+ // macOS common JDK locations
50
+ try {
51
+ const homes = execSync('ls -1 /Library/Java/JavaVirtualMachines || true', { stdio: ['ignore', 'pipe', 'inherit'] }).toString().split(/\r?\n/).filter(Boolean)
52
+ for (const h of homes) {
53
+ if (h.toLowerCase().includes('17') || h.toLowerCase().includes('jdk-17')) {
54
+ const candidate = `/Library/Java/JavaVirtualMachines/${h}/Contents/Home`
55
+ return candidate
56
+ }
57
+ }
58
+ } catch {}
59
+
60
+ // Linux locations
61
+ const linuxCandidates = [
62
+ '/usr/lib/jvm/java-17-openjdk-amd64',
63
+ '/usr/lib/jvm/java-17-openjdk',
64
+ '/usr/lib/jvm/zulu17',
65
+ '/usr/lib/jvm/temurin-17-jdk'
66
+ ]
67
+ for (const p of linuxCandidates) {
68
+ try { if (existsSync(p)) return p } catch {}
69
+ }
70
+ } catch (e) {}
71
+ return undefined
72
+ }
5
73
 
6
74
  // Helper to construct ADB args with optional device ID
7
75
  function getAdbArgs(args: string[], deviceId?: string): string[] {
@@ -35,12 +103,21 @@ export function execAdb(args: string[], deviceId?: string, options: any = {}): P
35
103
  })
36
104
  }
37
105
 
38
- let timeoutMs = customTimeout || 2000;
39
- if (!customTimeout) {
40
- if (args.includes('logcat')) {
106
+ let timeoutMs: number;
107
+ if (typeof customTimeout === 'number' && !isNaN(customTimeout)) {
108
+ timeoutMs = customTimeout;
109
+ } else {
110
+ const envTimeout = parseInt(process.env.MCP_ADB_TIMEOUT || process.env.ADB_TIMEOUT || '', 10);
111
+ if (!isNaN(envTimeout) && envTimeout > 0) {
112
+ timeoutMs = envTimeout;
113
+ } else {
114
+ if (args.includes('logcat')) {
41
115
  timeoutMs = 10000;
42
- } else if (args.includes('uiautomator') && args.includes('dump')) {
116
+ } else if (args.includes('uiautomator') && args.includes('dump')) {
43
117
  timeoutMs = 20000; // UI dump can be slow
118
+ } else {
119
+ timeoutMs = 120000; // default 2 minutes for installs and slow commands
120
+ }
44
121
  }
45
122
  }
46
123
 
@@ -79,16 +156,324 @@ export function getDeviceInfo(deviceId: string, metadata: Partial<DeviceInfo> =
79
156
 
80
157
  export async function getAndroidDeviceMetadata(appId: string, deviceId?: string): Promise<DeviceInfo> {
81
158
  try {
159
+ // If no deviceId provided, try to auto-detect a single connected device
160
+ let resolvedDeviceId = deviceId;
161
+ if (!resolvedDeviceId) {
162
+ try {
163
+ const devicesOutput = await execAdb(['devices']);
164
+ // Parse lines like: "<serial>\tdevice"
165
+ const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean);
166
+ const deviceLines = lines.slice(1) // skip header
167
+ .map(l => l.split('\t'))
168
+ .filter(parts => parts.length >= 2 && parts[1] === 'device')
169
+ .map(parts => parts[0]);
170
+ if (deviceLines.length === 1) {
171
+ resolvedDeviceId = deviceLines[0];
172
+ }
173
+ } catch (e) {
174
+ // ignore and continue without resolvedDeviceId
175
+ }
176
+ }
177
+
82
178
  // Run these in parallel to avoid sequential timeouts
83
179
  const [osVersion, model, simOutput] = await Promise.all([
84
- execAdb(['shell', 'getprop', 'ro.build.version.release'], deviceId).catch(() => ''),
85
- execAdb(['shell', 'getprop', 'ro.product.model'], deviceId).catch(() => ''),
86
- execAdb(['shell', 'getprop', 'ro.kernel.qemu'], deviceId).catch(() => '0')
180
+ execAdb(['shell', 'getprop', 'ro.build.version.release'], resolvedDeviceId).catch(() => ''),
181
+ execAdb(['shell', 'getprop', 'ro.product.model'], resolvedDeviceId).catch(() => ''),
182
+ execAdb(['shell', 'getprop', 'ro.kernel.qemu'], resolvedDeviceId).catch(() => '0')
87
183
  ])
88
184
 
89
185
  const simulator = simOutput === '1'
90
- return { platform: 'android', id: deviceId || 'default', osVersion, model, simulator }
186
+ return { platform: 'android', id: resolvedDeviceId || 'default', osVersion, model, simulator }
91
187
  } catch (e) {
92
188
  return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false }
93
189
  }
94
190
  }
191
+
192
+ export async function listAndroidDevices(appId?: string): Promise<DeviceInfo[]> {
193
+ try {
194
+ const devicesOutput = await execAdb(['devices', '-l'])
195
+ const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean)
196
+ // Skip header if present (some adb versions include 'List of devices attached')
197
+ const deviceLines = lines.filter(l => !l.startsWith('List of devices')).map(l => l)
198
+ const serials = deviceLines.map(line => line.split(/\s+/)[0]).filter(Boolean)
199
+
200
+ const infos = await Promise.all(serials.map(async (serial) => {
201
+ try {
202
+ const [osVersion, model, simOutput] = await Promise.all([
203
+ execAdb(['shell', 'getprop', 'ro.build.version.release'], serial).catch(() => ''),
204
+ execAdb(['shell', 'getprop', 'ro.product.model'], serial).catch(() => ''),
205
+ execAdb(['shell', 'getprop', 'ro.kernel.qemu'], serial).catch(() => '0')
206
+ ])
207
+ const simulator = simOutput === '1'
208
+ let appInstalled = false
209
+ if (appId) {
210
+ try {
211
+ const pm = await execAdb(['shell', 'pm', 'path', appId], serial)
212
+ appInstalled = !!(pm && pm.includes('package:'))
213
+ } catch {
214
+ appInstalled = false
215
+ }
216
+ }
217
+ return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled } as DeviceInfo & { appInstalled?: boolean }
218
+ } catch {
219
+ return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false } as DeviceInfo & { appInstalled?: boolean }
220
+ }
221
+ }))
222
+
223
+ return infos
224
+ } catch (e) {
225
+ return []
226
+ }
227
+ }
228
+
229
+ // Log stream management (one stream per session)
230
+
231
+ const activeLogStreams: Map<string, { proc: { kill: () => void } | ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
232
+
233
+ // Test helper to register a pre-existing NDJSON file as the active stream for a session (used by unit tests)
234
+ export function _setActiveLogStream(sessionId: string, file: string) {
235
+ activeLogStreams.set(sessionId, { proc: { kill: () => {} }, file })
236
+ }
237
+
238
+ export function _clearActiveLogStream(sessionId: string) {
239
+ activeLogStreams.delete(sessionId)
240
+ }
241
+
242
+ // Robust log line parser supporting multiple logcat formats
243
+ export function parseLogLine(line: string) {
244
+ // Collapse internal newlines so multiline stack traces are parseable as a single entry
245
+ const rawLine = line
246
+ const normalizedLine = rawLine.replace(/\r?\n/g, ' ')
247
+ const entry: any = { timestamp: '', level: '', tag: '', message: rawLine, _iso: null, crash: false }
248
+
249
+ const nowYear = new Date().getFullYear()
250
+
251
+ const tryIso = (ts: string) => {
252
+ if (!ts) return null
253
+ // If it's already ISO
254
+ if (/^\d{4}-\d{2}-\d{2}T/.test(ts)) return ts
255
+ // If format MM-DD HH:MM:SS(.sss)
256
+ const m = ts.match(/^(\d{2})-(\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/)
257
+ if (m) {
258
+ const month = m[1]
259
+ const day = m[2]
260
+ const time = m[3]
261
+ const candidate = `${nowYear}-${month}-${day}T${time}`
262
+ const d = new Date(candidate)
263
+ if (!isNaN(d.getTime())) return d.toISOString()
264
+ }
265
+ // If format YYYY-MM-DD HH:MM:SS(.sss)
266
+ const m2 = ts.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/)
267
+ if (m2) {
268
+ const candidate = `${m2[1]}T${m2[2]}`
269
+ const d = new Date(candidate)
270
+ if (!isNaN(d.getTime())) return d.toISOString()
271
+ }
272
+ return null
273
+ }
274
+
275
+ // Patterns to try (ordered)
276
+ const patterns: Array<{re: RegExp, groups: string[]}> = [
277
+ // MM-DD HH:MM:SS.mmm PID TID LEVEL/Tag: msg
278
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts','pid','tid','level','tag','msg'] },
279
+ // MM-DD HH:MM:SS.mmm PID TID LEVEL Tag: msg (space between level and tag)
280
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts','pid','tid','level','tag','msg'] },
281
+ // YYYY-MM-DD full date with PID TID LEVEL/Tag
282
+ { re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts','pid','tid','level','tag','msg'] },
283
+ // YYYY-MM-DD with space separation
284
+ { re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts','pid','tid','level','tag','msg'] },
285
+ // MM-DD PID LEVEL/Tag: msg
286
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts','pid','level','tag','msg'] },
287
+ // MM-DD PID LEVEL Tag: msg (space)
288
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts','pid','level','tag','msg'] },
289
+ // Short form LEVEL/Tag: msg
290
+ { re: /^([VDIWE])\/([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level','tag','msg'] },
291
+ // Short form LEVEL Tag: msg
292
+ { re: /^([VDIWE])\s+([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level','tag','msg'] },
293
+ ]
294
+
295
+ for (const p of patterns) {
296
+ const m = normalizedLine.match(p.re)
297
+ if (m) {
298
+ const g = p.groups
299
+ const vals: any = {}
300
+ for (let i=0;i<g.length;i++) vals[g[i]] = m[i+1]
301
+ const ts = vals.ts
302
+ if (ts) {
303
+ const iso = tryIso(ts)
304
+ if (iso) {
305
+ entry.timestamp = ts
306
+ entry._iso = iso
307
+ } else {
308
+ entry.timestamp = ts
309
+ }
310
+ }
311
+ if (vals.level) entry.level = vals.level
312
+ if (vals.tag) entry.tag = vals.tag.trim()
313
+ entry.message = vals.msg || entry.message
314
+ // Crash heuristics
315
+ const msg = (entry.message || '').toString()
316
+ const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg)
317
+ if (crash) {
318
+ entry.crash = true
319
+ const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/)
320
+ if (exMatch) entry.exception = exMatch[1]
321
+ }
322
+ return entry
323
+ }
324
+ }
325
+
326
+ // No pattern matched: attempt to extract level/tag like '... E/Tag: msg'
327
+ const alt = normalizedLine.match(/([VDIWE])\/([^:]+):\s*(.*)$/)
328
+ if (alt) {
329
+ entry.level = alt[1]
330
+ entry.tag = alt[2].trim()
331
+ entry.message = alt[3]
332
+ const msg = entry.message
333
+ const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg)
334
+ if (crash) {
335
+ entry.crash = true
336
+ const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/)
337
+ if (exMatch) entry.exception = exMatch[1]
338
+ }
339
+ }
340
+
341
+ return entry
342
+ }
343
+
344
+ export async function startAndroidLogStream(packageName: string, level: 'error' | 'warn' | 'info' | 'debug' = 'error', deviceId?: string, sessionId: string = 'default'): Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
345
+ try {
346
+ // Determine PID
347
+ const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '')
348
+ const pid = (pidOutput || '').trim()
349
+ if (!pid) {
350
+ return { success: false, error: 'app_not_running' }
351
+ }
352
+
353
+ // Map level to logcat filter
354
+ const levelMap: Record<string, string> = { error: '*:E', warn: '*:W', info: '*:I', debug: '*:D' }
355
+ const filter = levelMap[level] || levelMap['error']
356
+
357
+ // Prevent multiple streams per session
358
+ if (activeLogStreams.has(sessionId)) {
359
+ // stop existing
360
+ try { activeLogStreams.get(sessionId)!.proc.kill() } catch(e) {}
361
+ activeLogStreams.delete(sessionId)
362
+ }
363
+
364
+ // Start logcat process
365
+ const args = ['logcat', `--pid=${pid}`, filter]
366
+ const proc = spawn(ADB, args)
367
+
368
+ // Prepare output file
369
+ const tmpDir = process.env.TMPDIR || '/tmp'
370
+ const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`)
371
+ const stream = createWriteStream(file, { flags: 'a' })
372
+
373
+ proc.stdout.on('data', (chunk) => {
374
+ const text = chunk.toString()
375
+ const lines = text.split(/\r?\n/).filter(Boolean)
376
+ for (const l of lines) {
377
+ const entry = parseLogLine(l)
378
+ stream.write(JSON.stringify(entry) + '\n')
379
+ }
380
+ })
381
+
382
+ proc.stderr.on('data', (chunk) => {
383
+ // write stderr lines as message with level 'E'
384
+ const text = chunk.toString()
385
+ const lines = text.split(/\r?\n/).filter(Boolean)
386
+ for (const l of lines) {
387
+ const entry = { timestamp: '', level: 'E', tag: 'adb', message: l }
388
+ stream.write(JSON.stringify(entry) + '\n')
389
+ }
390
+ })
391
+
392
+ proc.on('close', (code) => {
393
+ stream.end()
394
+ activeLogStreams.delete(sessionId)
395
+ })
396
+
397
+ activeLogStreams.set(sessionId, { proc, file })
398
+
399
+ return { success: true, stream_started: true }
400
+ } catch (err) {
401
+ return { success: false, error: 'log_stream_start_failed' }
402
+ }
403
+ }
404
+
405
+ export async function stopAndroidLogStream(sessionId: string = 'default'): Promise<{ success: boolean }> {
406
+ const entry = activeLogStreams.get(sessionId)
407
+ if (!entry) return { success: true }
408
+ try {
409
+ entry.proc.kill()
410
+ } catch (e) {}
411
+ activeLogStreams.delete(sessionId)
412
+ return { success: true }
413
+ }
414
+
415
+ export async function readLogStreamLines(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
416
+ const entry = activeLogStreams.get(sessionId)
417
+ if (!entry) return { entries: [] }
418
+ try {
419
+ const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '')
420
+ if (!data) return { entries: [], crash_summary: { crash_detected: false } }
421
+ const lines = data.split(/\r?\n/).filter(Boolean)
422
+
423
+ // Parse NDJSON lines into objects. Prefer fields written by parseLogLine. For backward compatibility, if _iso or crash are missing, enrich minimally here (avoid duplicating full parse logic).
424
+ const parsed = lines.map(l => {
425
+ try {
426
+ const obj: any = JSON.parse(l)
427
+ // Ensure _iso: if missing, try to derive using Date()
428
+ if (typeof obj._iso === 'undefined') {
429
+ let iso: string | null = null
430
+ if (obj.timestamp) {
431
+ const d = new Date(obj.timestamp)
432
+ if (!isNaN(d.getTime())) iso = d.toISOString()
433
+ }
434
+ obj._iso = iso
435
+ }
436
+ // Ensure crash flag: if missing, run minimal heuristic
437
+ if (typeof obj.crash === 'undefined') {
438
+ const msg = (obj.message || '').toString()
439
+ const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/)
440
+ if (/FATAL EXCEPTION/i.test(msg) || exMatch) {
441
+ obj.crash = true
442
+ if (exMatch) obj.exception = exMatch[1]
443
+ } else {
444
+ obj.crash = false
445
+ }
446
+ }
447
+ return obj
448
+ } catch {
449
+ return { message: l, _iso: null, crash: false }
450
+ }
451
+ })
452
+
453
+ // Filter by since if provided (accept ISO or epoch ms)
454
+ let filtered = parsed
455
+ if (since) {
456
+ let sinceMs: number | null = null
457
+ // If numeric string
458
+ if (/^\d+$/.test(since)) sinceMs = Number(since)
459
+ else {
460
+ const sDate = new Date(since)
461
+ if (!isNaN(sDate.getTime())) sinceMs = sDate.getTime()
462
+ }
463
+ if (sinceMs !== null) {
464
+ filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs))
465
+ }
466
+ }
467
+
468
+ // Return the last `limit` entries (most recent)
469
+ const entries = filtered.slice(-Math.max(0, limit))
470
+
471
+ // Crash summary
472
+ const crashEntry = entries.find(e => e.crash)
473
+ const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false }
474
+
475
+ return { entries, crash_summary }
476
+ } catch (e) {
477
+ return { entries: [], crash_summary: { crash_detected: false } }
478
+ }
479
+ }
@@ -3,6 +3,8 @@ import { spawn } from "child_process"
3
3
  import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, WaitForElementResponse, TapResponse } from "../types.js"
4
4
  import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB } from "./utils.js"
5
5
  import { iOSObserve } from "./observe.js"
6
+ import path from "path"
7
+ import { existsSync } from "fs"
6
8
 
7
9
  export class iOSInteract {
8
10
  private observe = new iOSObserve();
@@ -81,6 +83,106 @@ export class iOSInteract {
81
83
  }
82
84
  }
83
85
 
86
+ async installApp(appPath: string, deviceId: string = "booted"): Promise<import("../types.js").InstallAppResponse> {
87
+ const device = await getIOSDeviceMetadata(deviceId)
88
+
89
+ // Helper to find .app bundles under a directory
90
+ async function findAppBundle(dir: string): Promise<string | undefined> {
91
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
92
+ for (const e of entries) {
93
+ const full = path.join(dir, e.name)
94
+ if (e.isDirectory()) {
95
+ if (full.endsWith('.app')) return full
96
+ const found = await findAppBundle(full)
97
+ if (found) return found
98
+ }
99
+ }
100
+ return undefined
101
+ }
102
+
103
+ try {
104
+ let toInstall = appPath
105
+
106
+ const stat = await fs.stat(appPath).catch(() => null)
107
+ if (stat && stat.isDirectory()) {
108
+ // If directory already contains a .app, use it
109
+ const found = await findAppBundle(appPath)
110
+ if (found) {
111
+ toInstall = found
112
+ } else {
113
+ // Attempt to locate an Xcode project and build for simulator
114
+ const files = await fs.readdir(appPath).catch(() => [])
115
+ // Prefer workspace when present (CocoaPods / multi-project setups)
116
+ const workspace = files.find(f => f.endsWith('.xcworkspace'))
117
+ const proj = files.find(f => f.endsWith('.xcodeproj'))
118
+ if (!workspace && !proj) throw new Error('No .app bundle, .xcworkspace or .xcodeproj found in directory')
119
+
120
+ let buildArgs: string[]
121
+ let scheme: string
122
+ if (workspace) {
123
+ const workspacePath = path.join(appPath, workspace)
124
+ scheme = workspace.replace(/\.xcworkspace$/, '')
125
+ buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet']
126
+ } else {
127
+ const projectPath = path.join(appPath, proj!)
128
+ scheme = proj!.replace(/\.xcodeproj$/, '')
129
+ buildArgs = ['-project', projectPath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet']
130
+ }
131
+
132
+ await new Promise<void>((resolve, reject) => {
133
+ const proc = spawn('xcodebuild', buildArgs, { cwd: appPath })
134
+ let stderr = ''
135
+ proc.stderr?.on('data', d => stderr += d.toString())
136
+ proc.on('close', code => {
137
+ if (code === 0) resolve()
138
+ else reject(new Error(stderr || `xcodebuild failed with code ${code}`))
139
+ })
140
+ proc.on('error', err => reject(err))
141
+ })
142
+
143
+ const built = await findAppBundle(appPath)
144
+ if (!built) throw new Error('Could not locate built .app after xcodebuild')
145
+ toInstall = built
146
+ }
147
+ }
148
+
149
+ // Try simulator install first
150
+ try {
151
+ const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId)
152
+ return { device, installed: true, output: res.output }
153
+ } catch (e) {
154
+ // If simctl fails and idb is available, try idb install for physical devices
155
+ try {
156
+ const child = spawn(IDB, ['--version'])
157
+ const idbExists = await new Promise<boolean>((resolve) => {
158
+ child.on('error', () => resolve(false));
159
+ child.on('close', (code) => resolve(code === 0));
160
+ });
161
+ if (idbExists) {
162
+ // Use idb to install (works for physical devices and simulators)
163
+ await new Promise<void>((resolve, reject) => {
164
+ const proc = spawn(IDB, ['install', toInstall, '--udid', device.id]);
165
+ let stderr = '';
166
+ proc.stderr.on('data', d => stderr += d.toString());
167
+ proc.on('close', code => {
168
+ if (code === 0) resolve();
169
+ else reject(new Error(stderr || `idb install failed with code ${code}`));
170
+ });
171
+ proc.on('error', err => reject(err));
172
+ });
173
+ return { device, installed: true }
174
+ }
175
+ } catch (inner) {
176
+ // fallthrough
177
+ }
178
+
179
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e) }
180
+ }
181
+ } catch (e) {
182
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e) }
183
+ }
184
+ }
185
+
84
186
  async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
85
187
  validateBundleId(bundleId)
86
188
  const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)