mobile-debug-mcp 0.18.0 → 0.19.1
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 +164 -0
- package/dist/server.js +19 -0
- package/dist/utils/android/utils.js +110 -12
- package/dist/utils/cli/ios/preflight-ios.js +1 -1
- package/dist/utils/java.js +91 -34
- package/docs/CHANGELOG.md +9 -0
- package/docs/tools/interact.md +52 -0
- package/package.json +1 -1
- package/src/interact/index.ts +145 -0
- package/src/manage/android.ts +3 -3
- package/src/manage/ios.ts +3 -3
- package/src/server.ts +20 -3
- package/src/utils/android/utils.ts +90 -19
- package/src/utils/cli/ios/preflight-ios.ts +2 -2
- package/src/utils/java.ts +76 -30
- package/test/interact/device/observe_until_device.ts +24 -0
- package/test/interact/unit/observe_until.test.ts +76 -0
- package/test/unit/index.ts +1 -0
- package/test/utils/detect_java.test.ts +25 -0
package/src/interact/index.ts
CHANGED
|
@@ -29,6 +29,8 @@ interface UiElement {
|
|
|
29
29
|
_interactable?: boolean
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const STABLE_IDLE_MS = 1000
|
|
33
|
+
|
|
32
34
|
export class ToolsInteract {
|
|
33
35
|
|
|
34
36
|
private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
|
|
@@ -260,4 +262,147 @@ export class ToolsInteract {
|
|
|
260
262
|
return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
|
|
261
263
|
}
|
|
262
264
|
|
|
265
|
+
static async observeUntilHandler({ type, query, timeoutMs = 5000, pollIntervalMs = 200, platform, deviceId }: { type: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
|
|
266
|
+
const start = Date.now()
|
|
267
|
+
const deadline = start + (timeoutMs || 0)
|
|
268
|
+
const q = (query === null || query === undefined) ? '' : String(query)
|
|
269
|
+
|
|
270
|
+
// Baseline state
|
|
271
|
+
let initialFingerprint: string | null = null
|
|
272
|
+
try {
|
|
273
|
+
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
274
|
+
initialFingerprint = fpRes?.fingerprint ?? null
|
|
275
|
+
} catch (err) { console.error('observeUntil: error getting initial fingerprint', err); initialFingerprint = null }
|
|
276
|
+
|
|
277
|
+
// For logs, capture a baseline snapshot (count or last line) to avoid matching historical lines
|
|
278
|
+
let baselineLastLine: string | null = null
|
|
279
|
+
try {
|
|
280
|
+
const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 })
|
|
281
|
+
const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
|
|
282
|
+
baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
|
|
283
|
+
} catch (err) {
|
|
284
|
+
// non-fatal but surface warning to aid debugging
|
|
285
|
+
try { console.warn('observeUntil: failed to get baseline logs (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
let lastChangeAt = Date.now()
|
|
290
|
+
let prevFingerprint = initialFingerprint
|
|
291
|
+
|
|
292
|
+
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
293
|
+
|
|
294
|
+
// Telemetry
|
|
295
|
+
let pollCount = 0
|
|
296
|
+
let timeToMatch: number | null = null
|
|
297
|
+
let matchSource: string | null = null
|
|
298
|
+
|
|
299
|
+
while (Date.now() <= deadline) {
|
|
300
|
+
pollCount++
|
|
301
|
+
try {
|
|
302
|
+
if (type === 'ui') {
|
|
303
|
+
// fast findElement with short timeout to avoid blocking
|
|
304
|
+
try {
|
|
305
|
+
const found = await ToolsInteract.findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, timeoutMs || 500), platform, deviceId })
|
|
306
|
+
if (found && (found as any).found) {
|
|
307
|
+
timeToMatch = Date.now() - start
|
|
308
|
+
// determine matchSource heuristics
|
|
309
|
+
const el = (found as any).element || {}
|
|
310
|
+
if (el && el.resourceId && String(el.resourceId).toLowerCase().includes(q.toLowerCase())) matchSource = 'ui-resourceId'
|
|
311
|
+
else if (el && el.text && String(el.text).toLowerCase() === q.toLowerCase()) matchSource = 'ui-exact'
|
|
312
|
+
else matchSource = 'ui-partial'
|
|
313
|
+
|
|
314
|
+
return { success: true, type: 'ui', matched: true, details: `UI element matched '${q}'`, timestamp: Date.now(), element: (found as any).element, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
|
|
315
|
+
}
|
|
316
|
+
} catch (err) { console.error('observeUntil(ui) find error:', err) }
|
|
317
|
+
} else if (type === 'log') {
|
|
318
|
+
try {
|
|
319
|
+
// Try reading from active stream first
|
|
320
|
+
const stream = await ToolsObserve.readLogStreamHandler({ platform, sessionId: 'default', limit: 200 }) as any
|
|
321
|
+
const entries = (stream && Array.isArray(stream.entries)) ? stream.entries : []
|
|
322
|
+
for (const ent of entries) {
|
|
323
|
+
const msg = ent && (ent.message || ent.msg || ent) ? (ent.message || ent.msg || ent) : ''
|
|
324
|
+
if (q && String(msg).includes(q)) {
|
|
325
|
+
timeToMatch = Date.now() - start
|
|
326
|
+
matchSource = 'log-stream'
|
|
327
|
+
return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: msg, raw: ent }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Fallback to snapshot logs
|
|
332
|
+
const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
|
|
333
|
+
const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
|
|
334
|
+
// Only consider new lines after baselineLastLine when possible
|
|
335
|
+
let startIndex = 0
|
|
336
|
+
if (baselineLastLine) {
|
|
337
|
+
const idx = logsArr.lastIndexOf(baselineLastLine)
|
|
338
|
+
startIndex = idx >= 0 ? idx + 1 : 0
|
|
339
|
+
}
|
|
340
|
+
for (let i = startIndex; i < logsArr.length; i++) {
|
|
341
|
+
const line = logsArr[i]
|
|
342
|
+
if (q && String(line).includes(q)) {
|
|
343
|
+
timeToMatch = Date.now() - start
|
|
344
|
+
matchSource = 'log-snapshot'
|
|
345
|
+
return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: line }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch (err) { console.error('observeUntil(log) error:', err) }
|
|
349
|
+
} else if (type === 'screen') {
|
|
350
|
+
try {
|
|
351
|
+
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
352
|
+
const fp = fpRes?.fingerprint ?? null
|
|
353
|
+
if (fp !== null && fp !== undefined && fp !== initialFingerprint) {
|
|
354
|
+
if (q) {
|
|
355
|
+
// optionally validate query against new screen context
|
|
356
|
+
try {
|
|
357
|
+
const found = await ToolsInteract.findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, timeoutMs || 500), platform, deviceId })
|
|
358
|
+
if (found && (found as any).found) {
|
|
359
|
+
timeToMatch = Date.now() - start
|
|
360
|
+
matchSource = 'screen-validated-ui'
|
|
361
|
+
return { success: true, type: 'screen', matched: true, details: `Screen changed and query matched on new screen`, timestamp: Date.now(), newFingerprint: fp, element: (found as any).element, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
|
|
362
|
+
}
|
|
363
|
+
} catch (err) { console.error('observeUntil(screen) find error:', err) }
|
|
364
|
+
// If query provided but not matched yet, continue polling until timeout
|
|
365
|
+
} else {
|
|
366
|
+
timeToMatch = Date.now() - start
|
|
367
|
+
matchSource = 'screen-fingerprint'
|
|
368
|
+
return { success: true, type: 'screen', matched: true, details: 'Screen fingerprint changed', timestamp: Date.now(), newFingerprint: fp, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch (err) { console.error('observeUntil(screen) error:', err) }
|
|
372
|
+
} else if (type === 'idle') {
|
|
373
|
+
try {
|
|
374
|
+
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
375
|
+
const fp = fpRes?.fingerprint ?? null
|
|
376
|
+
if (fp !== prevFingerprint) {
|
|
377
|
+
prevFingerprint = fp
|
|
378
|
+
lastChangeAt = Date.now()
|
|
379
|
+
} else {
|
|
380
|
+
if (Date.now() - lastChangeAt >= STABLE_IDLE_MS) {
|
|
381
|
+
timeToMatch = Date.now() - start
|
|
382
|
+
matchSource = 'idle-stable'
|
|
383
|
+
return { success: true, type: 'idle', matched: true, details: `UI stable for ${STABLE_IDLE_MS}ms`, timestamp: Date.now(), fingerprint: fp, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} catch (err) { console.error('observeUntil(idle) error:', err) }
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.error('observeUntil: unexpected error', err)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Respect poll interval and avoid tight loop
|
|
393
|
+
await sleep(pollIntervalMs || 200)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// On timeout, capture a failure snapshot to aid debugging (best-effort)
|
|
397
|
+
let snapshot: any = null
|
|
398
|
+
try {
|
|
399
|
+
snapshot = await ToolsObserve.captureDebugSnapshotHandler({ reason: `observe_until timeout for ${type}`, includeLogs: true, platform, deviceId })
|
|
400
|
+
} catch (err) {
|
|
401
|
+
snapshot = { error: err instanceof Error ? err.message : String(err) }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const elapsed = Date.now() - start
|
|
405
|
+
return { success: false, error: 'Timeout waiting for condition', type, timeoutMs, telemetry: { pollCount, elapsedMs: elapsed, matchSource: null }, snapshot }
|
|
406
|
+
}
|
|
407
|
+
|
|
263
408
|
}
|
package/src/manage/android.ts
CHANGED
|
@@ -115,7 +115,7 @@ export class AndroidManage {
|
|
|
115
115
|
try {
|
|
116
116
|
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
117
117
|
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
|
|
118
|
-
} catch (e:
|
|
118
|
+
} catch (e: unknown) {
|
|
119
119
|
const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
120
120
|
return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
|
|
121
121
|
}
|
|
@@ -127,7 +127,7 @@ export class AndroidManage {
|
|
|
127
127
|
try {
|
|
128
128
|
await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
|
|
129
129
|
return { device: deviceInfo, appTerminated: true }
|
|
130
|
-
} catch (e:
|
|
130
|
+
} catch (e: unknown) {
|
|
131
131
|
const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
|
|
132
132
|
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
|
|
133
133
|
}
|
|
@@ -149,7 +149,7 @@ export class AndroidManage {
|
|
|
149
149
|
try {
|
|
150
150
|
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
|
|
151
151
|
return { device: deviceInfo, dataCleared: output === 'Success' }
|
|
152
|
-
} catch (e:
|
|
152
|
+
} catch (e: unknown) {
|
|
153
153
|
const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId)
|
|
154
154
|
return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
|
|
155
155
|
}
|
package/src/manage/ios.ts
CHANGED
|
@@ -302,7 +302,7 @@ export class iOSManage {
|
|
|
302
302
|
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
303
303
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
304
304
|
return { device, appStarted: !!result.output, launchTimeMs: 1000 }
|
|
305
|
-
} catch (e:
|
|
305
|
+
} catch (e: unknown) {
|
|
306
306
|
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
307
307
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
308
308
|
return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
|
|
@@ -315,7 +315,7 @@ export class iOSManage {
|
|
|
315
315
|
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
316
316
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
317
317
|
return { device, appTerminated: true }
|
|
318
|
-
} catch (e:
|
|
318
|
+
} catch (e: unknown) {
|
|
319
319
|
const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
320
320
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
321
321
|
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
|
|
@@ -351,7 +351,7 @@ export class iOSManage {
|
|
|
351
351
|
} catch (e) {
|
|
352
352
|
throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`)
|
|
353
353
|
}
|
|
354
|
-
} catch (e:
|
|
354
|
+
} catch (e: unknown) {
|
|
355
355
|
const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
|
|
356
356
|
return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
|
|
357
357
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
4
|
+
import type { SchemaOutput } from "@modelcontextprotocol/sdk/server/zod-compat.js"
|
|
4
5
|
import {
|
|
5
6
|
ListToolsRequestSchema,
|
|
6
7
|
CallToolRequestSchema
|
|
@@ -19,6 +20,7 @@ import { ToolsInteract } from './interact/index.js'
|
|
|
19
20
|
import { ToolsObserve } from './observe/index.js'
|
|
20
21
|
import { AndroidManage } from './manage/index.js'
|
|
21
22
|
import { iOSManage } from './manage/index.js'
|
|
23
|
+
import { ensureAdbAvailable } from './utils/android/utils.js'
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
const server = new Server(
|
|
@@ -31,7 +33,22 @@ const server = new Server(
|
|
|
31
33
|
tools: {}
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
|
-
)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Startup healthchecks (non-fatal) — verify adb availability and log chosen command
|
|
39
|
+
(async () => {
|
|
40
|
+
try {
|
|
41
|
+
const adbCheck = ensureAdbAvailable()
|
|
42
|
+
if (adbCheck.ok) console.debug('[startup] adb available:', adbCheck.adbCmd, adbCheck.version)
|
|
43
|
+
else console.warn('[startup] adb not available or failed to run:', adbCheck.adbCmd, adbCheck.error)
|
|
44
|
+
} catch (e: unknown) {
|
|
45
|
+
if (e instanceof Error) {
|
|
46
|
+
console.warn('[startup] error during adb healthcheck:', e.message)
|
|
47
|
+
} else {
|
|
48
|
+
console.warn('[startup] error during adb healthcheck:', String(e))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})()
|
|
35
52
|
|
|
36
53
|
function wrapResponse<T>(data: T) {
|
|
37
54
|
return {
|
|
@@ -487,9 +504,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
487
504
|
}
|
|
488
505
|
}
|
|
489
506
|
]
|
|
490
|
-
}))
|
|
507
|
+
}));
|
|
491
508
|
|
|
492
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
509
|
+
server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typeof CallToolRequestSchema>) => {
|
|
493
510
|
const { name, arguments: args } = request.params
|
|
494
511
|
|
|
495
512
|
try {
|
|
@@ -3,8 +3,51 @@ import { promises as fsPromises, existsSync } from 'fs'
|
|
|
3
3
|
import path from 'path'
|
|
4
4
|
import { detectJavaHome } from '../java.js'
|
|
5
5
|
import { execCmd } from '../exec.js'
|
|
6
|
+
import { spawnSync } from 'child_process'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
function findInPath(cmd: string): string | null {
|
|
9
|
+
try {
|
|
10
|
+
// prefer command -v for POSIX
|
|
11
|
+
const res = spawnSync('command', ['-v', cmd], { encoding: 'utf8' })
|
|
12
|
+
if (res.status === 0 && res.stdout) return res.stdout.trim()
|
|
13
|
+
} catch (e: unknown) { console.debug(`[findInPath] command -v ${cmd} failed: ${String(e)}`) }
|
|
14
|
+
try {
|
|
15
|
+
const res = spawnSync('which', [cmd], { encoding: 'utf8' })
|
|
16
|
+
if (res.status === 0 && res.stdout) return res.stdout.trim()
|
|
17
|
+
} catch (e: unknown) { console.debug(`[findInPath] which ${cmd} failed: ${String(e)}`) }
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveAdbCmd(): string {
|
|
22
|
+
// Priority: explicit env ADB_PATH -> ANDROID_SDK_ROOT/platform-tools/adb -> ANDROID_HOME/platform-tools/adb -> ~/Library/Android/sdk/platform-tools/adb -> PATH discovery -> 'adb'
|
|
23
|
+
if (process.env.ADB_PATH && process.env.ADB_PATH.trim()) return process.env.ADB_PATH
|
|
24
|
+
const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME
|
|
25
|
+
if (sdkRoot) {
|
|
26
|
+
const candidate = path.join(sdkRoot, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb')
|
|
27
|
+
if (existsSync(candidate)) return candidate
|
|
28
|
+
}
|
|
29
|
+
// common macOS user SDK path
|
|
30
|
+
const homeSdk = path.join(process.env.HOME || '', 'Library', 'Android', 'sdk', 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb')
|
|
31
|
+
if (existsSync(homeSdk)) return homeSdk
|
|
32
|
+
const found = findInPath('adb')
|
|
33
|
+
if (found) return found
|
|
34
|
+
return 'adb'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getAdbCmd() { return resolveAdbCmd() }
|
|
38
|
+
|
|
39
|
+
export function ensureAdbAvailable() {
|
|
40
|
+
const adb = resolveAdbCmd()
|
|
41
|
+
try {
|
|
42
|
+
const res = spawnSync(adb, ['--version'], { encoding: 'utf8' })
|
|
43
|
+
if (res.status === 0) {
|
|
44
|
+
return { adbCmd: adb, ok: true, version: (res.stdout || res.stderr || '').trim() }
|
|
45
|
+
}
|
|
46
|
+
return { adbCmd: adb, ok: false, error: (res.stderr || res.stdout || '').trim() }
|
|
47
|
+
} catch (err: unknown) {
|
|
48
|
+
return { adbCmd: adb, ok: false, error: String(err) }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
8
51
|
|
|
9
52
|
/**
|
|
10
53
|
* Prepare Gradle execution options for building an Android project.
|
|
@@ -31,22 +74,58 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
|
|
|
31
74
|
|
|
32
75
|
const detectedJavaHome = await detectJavaHome().catch(() => undefined)
|
|
33
76
|
const env = Object.assign({}, process.env)
|
|
77
|
+
|
|
78
|
+
// Ensure child processes can find Android platform-tools (adb, etc.) by
|
|
79
|
+
// prepending the platform-tools directory to PATH for spawned processes.
|
|
80
|
+
const adbPath = resolveAdbCmd()
|
|
81
|
+
let platformToolsDir: string | undefined = undefined
|
|
82
|
+
try {
|
|
83
|
+
if (adbPath && adbPath !== 'adb' && existsSync(adbPath)) {
|
|
84
|
+
platformToolsDir = path.dirname(adbPath)
|
|
85
|
+
}
|
|
86
|
+
} catch (e: unknown) { console.debug(`[prepareGradle] error resolving adbPath: ${String(e)}`) }
|
|
87
|
+
|
|
88
|
+
const pathParts: string[] = []
|
|
34
89
|
if (detectedJavaHome) {
|
|
35
90
|
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
36
91
|
env.JAVA_HOME = detectedJavaHome
|
|
37
|
-
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
|
|
38
92
|
}
|
|
93
|
+
const javaBin = path.join(detectedJavaHome, 'bin')
|
|
94
|
+
pathParts.push(javaBin)
|
|
39
95
|
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
|
|
40
96
|
gradleArgs.push('--no-daemon')
|
|
41
97
|
env.GRADLE_JAVA_HOME = detectedJavaHome
|
|
42
98
|
}
|
|
43
99
|
|
|
44
|
-
|
|
100
|
+
if (platformToolsDir) {
|
|
101
|
+
// Prepend platform-tools so gradle and child tools find adb without modifying global env
|
|
102
|
+
if (!env.PATH || !env.PATH.includes(platformToolsDir)) {
|
|
103
|
+
pathParts.push(platformToolsDir)
|
|
104
|
+
}
|
|
105
|
+
} else if (process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME) {
|
|
106
|
+
const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || ''
|
|
107
|
+
const candidate = path.join(sdkRoot, 'platform-tools')
|
|
108
|
+
if (existsSync(candidate) && (!env.PATH || !env.PATH.includes(candidate))) {
|
|
109
|
+
pathParts.push(candidate)
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
// also try common user sdk location
|
|
113
|
+
const homeSdkTools = path.join(process.env.HOME || '', 'Library', 'Android', 'sdk', 'platform-tools')
|
|
114
|
+
if (existsSync(homeSdkTools) && (!env.PATH || !env.PATH.includes(homeSdkTools))) {
|
|
115
|
+
pathParts.push(homeSdkTools)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (pathParts.length > 0) {
|
|
120
|
+
env.PATH = `${pathParts.join(path.delimiter)}${path.delimiter}${env.PATH || ''}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try { delete env.SHELL } catch (e: unknown) { console.debug('[prepareGradle] failed to delete SHELL from env:', String(e)) }
|
|
45
124
|
|
|
46
125
|
const useWrapper = existsSync(gradlewPath)
|
|
47
126
|
const spawnOpts: any = { cwd: projectPath, env }
|
|
48
127
|
if (useWrapper) {
|
|
49
|
-
try { await fsPromises.chmod(gradlewPath, 0o755) } catch {}
|
|
128
|
+
try { await fsPromises.chmod(gradlewPath, 0o755) } catch (e: unknown) { console.debug('[prepareGradle] chmod failed for gradlew:', String(e)) }
|
|
50
129
|
spawnOpts.shell = false
|
|
51
130
|
} else {
|
|
52
131
|
spawnOpts.shell = true
|
|
@@ -125,8 +204,7 @@ export async function getAndroidDeviceMetadata(appId: string, deviceId?: string)
|
|
|
125
204
|
if (deviceLines.length === 1) {
|
|
126
205
|
resolvedDeviceId = deviceLines[0];
|
|
127
206
|
}
|
|
128
|
-
} catch {
|
|
129
|
-
// ignore and continue without resolvedDeviceId
|
|
207
|
+
} catch (e: unknown) { console.debug('[getAndroidDeviceMetadata] error detecting single device: ' + String(e))
|
|
130
208
|
}
|
|
131
209
|
}
|
|
132
210
|
|
|
@@ -139,7 +217,8 @@ export async function getAndroidDeviceMetadata(appId: string, deviceId?: string)
|
|
|
139
217
|
|
|
140
218
|
const simulator = simOutput === '1'
|
|
141
219
|
return { platform: 'android', id: resolvedDeviceId || 'default', osVersion, model, simulator }
|
|
142
|
-
} catch {
|
|
220
|
+
} catch (e: unknown) {
|
|
221
|
+
console.debug('[getAndroidDeviceMetadata] failed to gather metadata: ' + String(e))
|
|
143
222
|
return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false }
|
|
144
223
|
}
|
|
145
224
|
}
|
|
@@ -180,20 +259,14 @@ export async function listAndroidDevices(appId?: string): Promise<DeviceInfo[]>
|
|
|
180
259
|
try {
|
|
181
260
|
const pm = await execAdb(['shell', 'pm', 'path', appId], serial)
|
|
182
261
|
appInstalled = !!(pm && pm.includes('package:'))
|
|
183
|
-
} catch {
|
|
184
|
-
appInstalled = false
|
|
185
|
-
}
|
|
262
|
+
} catch (e: unknown) { console.debug(`[listAndroidDevices] pm check failed for ${serial}: ${String(e)}`); appInstalled = false }
|
|
186
263
|
}
|
|
187
264
|
return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled } as DeviceInfo & { appInstalled?: boolean }
|
|
188
|
-
} catch {
|
|
189
|
-
return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false } as DeviceInfo & { appInstalled?: boolean }
|
|
190
|
-
}
|
|
265
|
+
} catch (e: unknown) { console.debug(`[listAndroidDevices] failed gathering metadata for ${serial}: ${String(e)}`); return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false } as DeviceInfo & { appInstalled?: boolean } }
|
|
191
266
|
}))
|
|
192
267
|
|
|
193
268
|
return infos
|
|
194
|
-
} catch {
|
|
195
|
-
return []
|
|
196
|
-
}
|
|
269
|
+
} catch (e: unknown) { console.debug('[listAndroidDevices] failed to list devices: ' + String(e)); return [] }
|
|
197
270
|
}
|
|
198
271
|
|
|
199
272
|
// UI helper utilities shared by observe/interact
|
|
@@ -219,9 +292,7 @@ export async function getScreenResolution(deviceId?: string): Promise<{ width: n
|
|
|
219
292
|
if (match) {
|
|
220
293
|
return { width: parseInt(match[1]), height: parseInt(match[2]) };
|
|
221
294
|
}
|
|
222
|
-
} catch {
|
|
223
|
-
// ignore
|
|
224
|
-
}
|
|
295
|
+
} catch (e: unknown) { console.debug('[getScreenResolution] failed to detect screen resolution: ' + String(e)) }
|
|
225
296
|
return { width: 0, height: 0 };
|
|
226
297
|
}
|
|
227
298
|
|
|
@@ -40,8 +40,8 @@ function startCompanionIfNeeded(companionPath: string | null, udid: string | nul
|
|
|
40
40
|
const child = spawn(companionPath, ['--udid', udid], { detached: true, stdio: 'ignore' })
|
|
41
41
|
child.unref()
|
|
42
42
|
return { started: true }
|
|
43
|
-
} catch (e:
|
|
44
|
-
return { started: false, error: e.message }
|
|
43
|
+
} catch (e: unknown) {
|
|
44
|
+
return { started: false, error: e instanceof Error ? e.message : String(e) }
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
package/src/utils/java.ts
CHANGED
|
@@ -2,68 +2,114 @@ import { execSync } from 'child_process'
|
|
|
2
2
|
import { existsSync } from 'fs'
|
|
3
3
|
import path from 'path'
|
|
4
4
|
|
|
5
|
+
function isJavaVersionAcceptable(output?: string | null): boolean {
|
|
6
|
+
if (!output) return false
|
|
7
|
+
const s = String(output)
|
|
8
|
+
// Accept Java 17 or 21 (common supported LTS for Android builds)
|
|
9
|
+
if (/\b17\b/.test(s) || /17\./.test(s)) return true
|
|
10
|
+
if (/\b21\b/.test(s) || /21\./.test(s)) return true
|
|
11
|
+
return false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from 'child_process'
|
|
15
|
+
function javaVersionOf(javaBin: string): string | undefined {
|
|
16
|
+
try {
|
|
17
|
+
const res = spawnSync(javaBin, ['-version'], { encoding: 'utf8' })
|
|
18
|
+
// Java prints version to stderr traditionally
|
|
19
|
+
const out = (res.stdout || '') + (res.stderr || '')
|
|
20
|
+
return out || undefined
|
|
21
|
+
} catch (e: unknown) { console.debug('[javaVersionOf] java -version failed: ' + String(e)); return undefined }
|
|
22
|
+
}
|
|
23
|
+
|
|
5
24
|
export async function detectJavaHome(): Promise<string | undefined> {
|
|
6
25
|
try {
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} catch {
|
|
15
|
-
console.debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK17')
|
|
26
|
+
// 1) Honor explicit ANDROID_STUDIO_JDK env (highest priority)
|
|
27
|
+
const envStudio = process.env.ANDROID_STUDIO_JDK || process.env.ANDROID_STUDIO_JBR
|
|
28
|
+
if (envStudio && existsSync(path.join(envStudio, 'bin', 'java'))) {
|
|
29
|
+
const v = javaVersionOf(path.join(envStudio, 'bin', 'java'))
|
|
30
|
+
if (isJavaVersionAcceptable(v)) {
|
|
31
|
+
console.debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio)
|
|
32
|
+
return envStudio
|
|
16
33
|
}
|
|
34
|
+
console.debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions')
|
|
17
35
|
}
|
|
18
36
|
|
|
19
|
-
//
|
|
20
|
-
const explicit = '/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home'
|
|
21
|
-
if (existsSync(explicit)) return explicit
|
|
22
|
-
|
|
23
|
-
// Android Studio JBR candidates
|
|
37
|
+
// 2) Android Studio JBR candidates (prefer these over JAVA_HOME)
|
|
24
38
|
const jbrCandidates = [
|
|
25
39
|
'/Applications/Android Studio.app/Contents/jbr',
|
|
40
|
+
'/Applications/Android Studio.app/Contents/jbr/Contents/Home',
|
|
26
41
|
'/Applications/Android Studio Preview.app/Contents/jbr',
|
|
42
|
+
'/Applications/Android Studio Preview.app/Contents/jbr/Contents/Home',
|
|
27
43
|
'/Applications/Android Studio Preview 2022.3.app/Contents/jbr',
|
|
28
|
-
'/Applications/Android Studio Preview
|
|
44
|
+
'/Applications/Android Studio Preview 2022.3.app/Contents/jbr/Contents/Home',
|
|
45
|
+
'/Applications/Android Studio Preview 2023.1.app/Contents/jbr',
|
|
46
|
+
'/Applications/Android Studio Preview 2023.1.app/Contents/jbr/Contents/Home'
|
|
29
47
|
]
|
|
30
48
|
for (const p of jbrCandidates) {
|
|
31
49
|
const javaBin = path.join(p, 'bin', 'java')
|
|
32
50
|
if (existsSync(javaBin)) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
const v = javaVersionOf(javaBin)
|
|
52
|
+
if (isJavaVersionAcceptable(v)) {
|
|
53
|
+
console.debug('[java.detect] Found Android Studio JBR at:', p)
|
|
54
|
+
return p
|
|
55
|
+
}
|
|
37
56
|
}
|
|
38
57
|
}
|
|
39
58
|
|
|
40
|
-
//
|
|
59
|
+
// 3) If JAVA_HOME set, validate it (accept 17 or 21)
|
|
60
|
+
if (process.env.JAVA_HOME) {
|
|
61
|
+
try {
|
|
62
|
+
const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java')
|
|
63
|
+
const v = javaVersionOf(javaBin)
|
|
64
|
+
if (isJavaVersionAcceptable(v)) {
|
|
65
|
+
console.debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME)
|
|
66
|
+
return process.env.JAVA_HOME
|
|
67
|
+
}
|
|
68
|
+
console.debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search')
|
|
69
|
+
} catch {
|
|
70
|
+
console.debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4) macOS explicit path for JDK 17
|
|
75
|
+
const explicit = '/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home'
|
|
76
|
+
if (existsSync(explicit)) return explicit
|
|
77
|
+
|
|
78
|
+
// 5) macOS /usr/libexec/java_home try supported versions
|
|
79
|
+
try {
|
|
80
|
+
const out17 = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
|
|
81
|
+
if (out17) return out17
|
|
82
|
+
} catch (e: unknown) { console.debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e)) }
|
|
41
83
|
try {
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
} catch {}
|
|
84
|
+
const out21 = execSync('/usr/libexec/java_home -v 21', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
|
|
85
|
+
if (out21) return out21
|
|
86
|
+
} catch (e: unknown) { console.debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e)) }
|
|
45
87
|
|
|
46
|
-
// macOS common JDK locations
|
|
88
|
+
// 6) macOS common JDK locations
|
|
47
89
|
try {
|
|
48
90
|
const homes = execSync('ls -1 /Library/Java/JavaVirtualMachines || true', { stdio: ['ignore', 'pipe', 'inherit'] }).toString().split(/\r?\n/).filter(Boolean)
|
|
49
91
|
for (const h of homes) {
|
|
50
|
-
if (h.toLowerCase().includes('17') || h.toLowerCase().includes('jdk-17')) {
|
|
92
|
+
if (h.toLowerCase().includes('17') || h.toLowerCase().includes('jdk-17') || h.toLowerCase().includes('21') || h.toLowerCase().includes('jdk-21')) {
|
|
51
93
|
const candidate = `/Library/Java/JavaVirtualMachines/${h}/Contents/Home`
|
|
52
94
|
return candidate
|
|
53
95
|
}
|
|
54
96
|
}
|
|
55
|
-
} catch {}
|
|
97
|
+
} catch (e: unknown) { console.debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e)) }
|
|
56
98
|
|
|
57
|
-
// Linux locations
|
|
99
|
+
// 7) Linux locations
|
|
58
100
|
const linuxCandidates = [
|
|
59
101
|
'/usr/lib/jvm/java-17-openjdk-amd64',
|
|
60
102
|
'/usr/lib/jvm/java-17-openjdk',
|
|
61
103
|
'/usr/lib/jvm/zulu17',
|
|
62
|
-
'/usr/lib/jvm/temurin-17-jdk'
|
|
104
|
+
'/usr/lib/jvm/temurin-17-jdk',
|
|
105
|
+
'/usr/lib/jvm/temurin-21-jdk',
|
|
106
|
+
'/usr/lib/jvm/java-21-openjdk-amd64'
|
|
63
107
|
]
|
|
64
108
|
for (const p of linuxCandidates) {
|
|
65
|
-
try { if (existsSync(p)) return p } catch {}
|
|
109
|
+
try { if (existsSync(p)) return p } catch (e: unknown) { console.debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`) }
|
|
66
110
|
}
|
|
67
|
-
} catch {
|
|
111
|
+
} catch (e: unknown) {
|
|
112
|
+
console.debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e))
|
|
113
|
+
}
|
|
68
114
|
return undefined
|
|
69
115
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
(async function main(){
|
|
2
|
+
try{
|
|
3
|
+
const inter = await import('../../src/interact/index.ts')
|
|
4
|
+
const manage = await import('../../src/manage/index.ts')
|
|
5
|
+
const ToolsInteract = (inter as any).ToolsInteract
|
|
6
|
+
const ToolsManage = (manage as any).ToolsManage
|
|
7
|
+
|
|
8
|
+
const ANDROID_ID = process.env.ANDROID_DEVICE || 'emulator-5554'
|
|
9
|
+
const IOS_UDID = process.env.IOS_DEVICE || '2EFFD8FD-5D09-47CC-95F8-28BBE30AF7ED'
|
|
10
|
+
console.log('Device test starting. Android:', ANDROID_ID, 'iOS:', IOS_UDID)
|
|
11
|
+
|
|
12
|
+
// Start modul8 on both platforms if present
|
|
13
|
+
try { await ToolsManage.startAppHandler({ platform: 'android', appId: 'com.ideamechanics.modul8', deviceId: ANDROID_ID }); console.log('Started android app (if installed)') } catch(e){ console.error('Android start skipped:', e.message || e) }
|
|
14
|
+
try { await ToolsManage.startAppHandler({ platform: 'ios', appId: 'com.ideamechanics.modul8.Modul8', deviceId: IOS_UDID }); console.log('Started ios app (if installed)') } catch(e){ console.error('iOS start skipped:', e.message || e) }
|
|
15
|
+
|
|
16
|
+
// Observe UI for Generate Session on both devices (will timeout if not present)
|
|
17
|
+
const aRes = await ToolsInteract.observeUntilHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 20000, pollIntervalMs: 500, platform: 'android', deviceId: ANDROID_ID })
|
|
18
|
+
console.log('Android observe result:', JSON.stringify(aRes, null, 2))
|
|
19
|
+
|
|
20
|
+
const iRes = await ToolsInteract.observeUntilHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 20000, pollIntervalMs: 500, platform: 'ios', deviceId: IOS_UDID })
|
|
21
|
+
console.log('iOS observe result:', JSON.stringify(iRes, null, 2))
|
|
22
|
+
|
|
23
|
+
} catch (e) { console.error('ERR', e); process.exit(1) }
|
|
24
|
+
})()
|