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.
- package/README.md +18 -443
- package/dist/android/interact.js +96 -1
- package/dist/android/utils.js +404 -12
- package/dist/ios/interact.js +105 -0
- package/dist/ios/utils.js +154 -0
- package/dist/resolve-device.js +70 -0
- package/dist/server.js +126 -194
- package/dist/tools/app.js +45 -0
- package/dist/tools/devices.js +5 -0
- package/dist/tools/install.js +47 -0
- package/dist/tools/logs.js +62 -0
- package/dist/tools/screenshot.js +17 -0
- package/dist/tools/ui.js +57 -0
- package/docs/CHANGELOG.md +19 -0
- package/docs/TOOLS.md +272 -0
- package/package.json +6 -2
- package/src/android/interact.ts +100 -1
- package/src/android/utils.ts +395 -10
- package/src/ios/interact.ts +102 -0
- package/src/ios/utils.ts +157 -0
- package/src/resolve-device.ts +80 -0
- package/src/server.ts +149 -276
- package/src/tools/app.ts +46 -0
- package/src/tools/devices.ts +6 -0
- package/src/tools/install.ts +43 -0
- package/src/tools/logs.ts +62 -0
- package/src/tools/screenshot.ts +18 -0
- package/src/tools/ui.ts +62 -0
- package/src/types.ts +7 -0
- package/test/integration/index.ts +8 -0
- package/test/integration/install.integration.ts +64 -0
- package/test/integration/logstream-real.ts +35 -0
- package/test/integration/run-install-android.ts +21 -0
- package/test/integration/run-install-ios.ts +21 -0
- package/test/integration/run-real-test.ts +19 -0
- package/{smoke-test.ts → test/integration/smoke-test.ts} +17 -25
- package/test/integration/test-dist.mjs +41 -0
- package/test/integration/test-dist.ts +41 -0
- package/{test-ui-tree.ts → test/integration/test-ui-tree.ts} +2 -2
- package/test/integration/wait_for_element_real.ts +80 -0
- package/test/unit/index.ts +7 -0
- package/test/unit/install.test.ts +82 -0
- package/test/unit/logparse.test.ts +41 -0
- package/test/unit/logstream.test.ts +46 -0
- package/test/unit/wait_for_element_mock.ts +104 -0
- package/tsconfig.json +1 -1
- package/smoke-test.js +0 -102
- package/test/run-real-test.js +0 -24
- package/test/wait_for_element_mock.js +0 -113
- package/test/wait_for_element_real.js +0 -67
- package/test-ui-tree.js +0 -68
package/src/android/utils.ts
CHANGED
|
@@ -1,7 +1,75 @@
|
|
|
1
|
-
import { spawn } from
|
|
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 ||
|
|
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
|
|
39
|
-
if (!customTimeout) {
|
|
40
|
-
|
|
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
|
-
|
|
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'],
|
|
85
|
-
execAdb(['shell', 'getprop', 'ro.product.model'],
|
|
86
|
-
execAdb(['shell', 'getprop', 'ro.kernel.qemu'],
|
|
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:
|
|
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
|
+
}
|
package/src/ios/interact.ts
CHANGED
|
@@ -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)
|