mobile-debug-mcp 0.20.0 → 0.21.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.
@@ -29,7 +29,6 @@ interface UiElement {
29
29
  _interactable?: boolean
30
30
  }
31
31
 
32
- const STABLE_IDLE_MS = 1000
33
32
 
34
33
  export class ToolsInteract {
35
34
 
@@ -262,76 +261,105 @@ export class ToolsInteract {
262
261
  return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
263
262
  }
264
263
 
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 }) {
264
+ static async observeUntilHandler({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
266
265
  const start = Date.now()
267
266
  const deadline = start + (timeoutMs || 0)
268
267
  const q = (query === null || query === undefined) ? '' : String(query)
269
268
 
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 }
269
+ // Clamp polling interval to 250-500ms for consistent behavior
270
+ const pollInterval = Math.max(250, Math.min(pollIntervalMs || 300, 500))
276
271
 
277
- // For logs, capture a baseline snapshot (count or last line) to avoid matching historical lines
272
+ // Baseline state (fetch in parallel but bound to short timeouts so observation starts promptly)
273
+ let initialFingerprint: string | null = null
278
274
  let baselineLastLine: string | null = null
279
275
  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
276
+ const fpPromise = ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as Promise<ScreenFingerprintResponse | null>
277
+ const logsPromise = ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as Promise<any>
278
+ const withTimeout = (p: Promise<any>, ms: number) => Promise.race([p, new Promise(resolve => setTimeout(() => resolve(null), ms))])
279
+ const [fpRes, gl] = await Promise.all([withTimeout(fpPromise, 300), withTimeout(logsPromise, 500)])
280
+ if (fpRes && typeof fpRes === 'object') initialFingerprint = (fpRes as ScreenFingerprintResponse).fingerprint ?? null
281
+ if (gl) {
282
+ const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
283
+ baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
284
+ }
283
285
  } 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
+ try { console.warn('observeUntil: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
286
287
  }
287
288
 
288
-
289
+ // Network-based waiting removed. Rely on UI and screen fingerprints for determinism.
289
290
  let lastChangeAt = Date.now()
290
291
  let prevFingerprint = initialFingerprint
291
292
 
292
293
  const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
293
294
 
295
+ // Optional initial observation delay requested by caller
296
+ if (typeof observationDelayMs === 'number' && observationDelayMs > 0) {
297
+ try { console.log(`observeUntil: delaying observation for ${observationDelayMs}ms`) } catch { }
298
+ await sleep(observationDelayMs)
299
+ }
300
+
294
301
  // Telemetry
295
302
  let pollCount = 0
296
- let timeToMatch: number | null = null
303
+ let matchedAt: number | null = null
304
+ let lastObservedState: boolean | null = null
305
+ let stableDuration = 0
297
306
  let matchSource: string | null = null
298
307
 
299
308
  while (Date.now() <= deadline) {
300
309
  pollCount++
301
- try {
310
+ const now = Date.now()
311
+ // Evaluate condition per type
302
312
  if (type === 'ui') {
303
- // fast findElement with short timeout to avoid blocking
304
313
  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
- }
314
+ // Lightweight UI check: fetch UI tree and perform a normalized substring match to reduce overhead
315
+ try {
316
+ // Bound the UI tree fetch to avoid long blocking calls; prefer quick failure over hanging a poll
317
+ const withTimeout = (p: Promise<any>, ms: number) => Promise.race([p, new Promise(resolve => setTimeout(()=>resolve(null), ms))])
318
+ const tree = await withTimeout(ToolsObserve.getUITreeHandler({ platform, deviceId }), Math.min(pollInterval, 500)) as any
319
+ const elems = Array.isArray(tree && tree.elements) ? tree.elements : []
320
+ const qnorm = q.toLowerCase()
321
+ let matched: any = null
322
+ for (const el of elems) {
323
+ try {
324
+ const txt = ((el && (el.text || el.label || el.value || el.contentDescription || el.accessibilityLabel)) || '')
325
+ if (!txt) continue
326
+ if (String(txt).toLowerCase().includes(qnorm)) { matched = el; break }
327
+ } catch { continue }
328
+ }
329
+ const isPresent = !!matched
330
+ const conditionTrue = (match === 'present') ? isPresent : !isPresent
331
+ if (conditionTrue) {
332
+ if (matchedAt === null) matchedAt = Date.now()
333
+ stableDuration = Date.now() - (matchedAt as number)
334
+ lastObservedState = true
335
+ if (stableDuration >= stability_ms) {
336
+ matchSource = 'ui-tree-' + (match === 'present' ? 'present' : 'absent')
337
+ const element = isPresent ? matched : null
338
+ const now2 = Date.now()
339
+ return { success: true, condition: match, query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: stableDuration, matchedElement: element, matchSource, timestamp: now2, type: 'ui', observed_state: lastObservedState ?? null }
340
+ }
341
+ } else {
342
+ matchedAt = null
343
+ stableDuration = 0
344
+ lastObservedState = false
345
+ }
346
+ } catch (err) { console.error('observeUntil(ui) tree error:', err) }
316
347
  } catch (err) { console.error('observeUntil(ui) find error:', err) }
317
348
  } else if (type === 'log') {
318
349
  try {
319
- // Try reading from active stream first
350
+ // Logs: presence semantics only (match 'present'). Stability not applicable (immediate)
320
351
  const stream = await ToolsObserve.readLogStreamHandler({ platform, sessionId: 'default', limit: 200 }) as any
321
352
  const entries = (stream && Array.isArray(stream.entries)) ? stream.entries : []
322
353
  for (const ent of entries) {
323
354
  const msg = ent && (ent.message || ent.msg || ent) ? (ent.message || ent.msg || ent) : ''
324
355
  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 } }
356
+ const now2 = Date.now()
357
+ return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: msg, raw: ent }, matchSource: 'log-stream', timestamp: now2, type: 'log', observed_state: true }
328
358
  }
329
359
  }
330
360
 
331
- // Fallback to snapshot logs
332
361
  const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
333
362
  const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
334
- // Only consider new lines after baselineLastLine when possible
335
363
  let startIndex = 0
336
364
  if (baselineLastLine) {
337
365
  const idx = logsArr.lastIndexOf(baselineLastLine)
@@ -340,9 +368,8 @@ export class ToolsInteract {
340
368
  for (let i = startIndex; i < logsArr.length; i++) {
341
369
  const line = logsArr[i]
342
370
  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 } }
371
+ const now2 = Date.now()
372
+ return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true }
346
373
  }
347
374
  }
348
375
  } catch (err) { console.error('observeUntil(log) error:', err) }
@@ -351,21 +378,20 @@ export class ToolsInteract {
351
378
  const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
352
379
  const fp = fpRes?.fingerprint ?? null
353
380
  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
381
+ // when screen changed, require stability_ms where fingerprint remains the same
382
+ if (matchedAt === null) matchedAt = now
383
+ const confirmFp = (await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null)?.fingerprint ?? null
384
+ if (confirmFp === fp) {
385
+ stableDuration = Date.now() - (matchedAt as number)
386
+ lastObservedState = true
387
+ if (stableDuration >= stability_ms) {
388
+ const now2 = Date.now()
389
+ return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: stableDuration, newFingerprint: fp, matchSource: 'screen-fingerprint', timestamp: now2, type: 'screen', observed_state: lastObservedState ?? null }
390
+ }
365
391
  } 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 } }
392
+ matchedAt = null
393
+ stableDuration = 0
394
+ lastObservedState = false
369
395
  }
370
396
  }
371
397
  } catch (err) { console.error('observeUntil(screen) error:', err) }
@@ -376,33 +402,35 @@ export class ToolsInteract {
376
402
  if (fp !== prevFingerprint) {
377
403
  prevFingerprint = fp
378
404
  lastChangeAt = Date.now()
405
+ matchedAt = null
406
+ stableDuration = 0
407
+ lastObservedState = false
379
408
  } 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 } }
409
+ const idleMs = Date.now() - lastChangeAt
410
+ lastObservedState = true
411
+ if (idleMs >= stability_ms) {
412
+ const now2 = Date.now()
413
+ return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: idleMs, matchSource: 'idle-stable', timestamp: now2, type: 'idle', observed_state: lastObservedState ?? null }
384
414
  }
385
415
  }
386
416
  } catch (err) { console.error('observeUntil(idle) error:', err) }
387
417
  }
388
- } catch (err) {
389
- console.error('observeUntil: unexpected error', err)
390
- }
391
418
 
392
419
  // Respect poll interval and avoid tight loop
393
- await sleep(pollIntervalMs || 200)
420
+ await sleep(pollInterval)
394
421
  }
395
422
 
396
- // On timeout, capture a failure snapshot to aid debugging (best-effort)
423
+ // On timeout, optionally capture a failure snapshot to aid debugging (best-effort)
397
424
  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) }
425
+ if (includeSnapshotOnFailure) {
426
+ try {
427
+ snapshot = await ToolsObserve.captureDebugSnapshotHandler({ reason: `observe_until timeout for ${type}`, includeLogs: true, platform, deviceId })
428
+ } catch (err) {
429
+ snapshot = { error: err instanceof Error ? err.message : String(err) }
430
+ }
402
431
  }
403
432
 
404
433
  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
-
434
+ return { success: false, condition: match, query: q, poll_count: pollCount, duration_ms: elapsed, stable_duration_ms: stableDuration, error: 'Timeout waiting for condition', snapshot, observed_state: lastObservedState ?? null }
435
+ }
408
436
  }
@@ -64,8 +64,12 @@ export class AndroidManage {
64
64
  const spawnOpts: any = { cwd: apkPath, env }
65
65
  if (useWrapper) {
66
66
  await fs.chmod(wrapperPath, 0o755).catch(() => {})
67
+ // Run wrapper directly to avoid shell splitting of args
67
68
  spawnOpts.shell = false
68
- } else spawnOpts.shell = true
69
+ } else {
70
+ // Execute gradle directly without a shell so paths with spaces are preserved
71
+ spawnOpts.shell = false
72
+ }
69
73
 
70
74
  const proc = spawn(execCmd, gradleArgs, spawnOpts)
71
75
  let stderr = ''
package/src/server.ts CHANGED
@@ -339,6 +339,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
339
339
  required: ["previousFingerprint"]
340
340
  }
341
341
  },
342
+ {
343
+ name: "observe_until",
344
+ description: "Wait for a UI condition (element present/absent) and require a stability window before returning success. Network-based waiting is not required; UI-only synchronization is the default and primary mode.",
345
+ inputSchema: {
346
+ type: "object",
347
+ 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 },
355
+ platform: { type: "string", enum: ["android","ios"], description: "Optional platform override" },
356
+ deviceId: { type: "string", description: "Optional device serial/udid" }
357
+ }
358
+ }
359
+ },
360
+
361
+
342
362
  {
343
363
  name: "wait_for_element",
344
364
  description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
@@ -0,0 +1,98 @@
1
+ import { readFileSync, existsSync } from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+
5
+ function readPropertiesFile(p: string): Record<string,string> {
6
+ try {
7
+ const txt = readFileSync(p, { encoding: 'utf8' })
8
+ const lines = String(txt).split(/\r?\n/)
9
+ const out: Record<string,string> = {}
10
+ for (const l of lines) {
11
+ const trimmed = l.trim()
12
+ if (!trimmed || trimmed.startsWith('#')) continue
13
+ const idx = trimmed.indexOf('=')
14
+ if (idx === -1) continue
15
+ const k = trimmed.substring(0, idx).trim()
16
+ const v = trimmed.substring(idx+1).trim()
17
+ out[k] = v
18
+ }
19
+ return out
20
+ } catch {
21
+ return {}
22
+ }
23
+ }
24
+
25
+ function javaBinExists(p?: string): boolean {
26
+ if (!p) return false
27
+ try {
28
+ const javaPath = path.join(p, 'bin', 'java')
29
+ if (existsSync(javaPath)) return true
30
+ const alt = path.join(p, 'Contents', 'Home', 'bin', 'java')
31
+ if (existsSync(alt)) return true
32
+ return false
33
+ } catch { return false }
34
+ }
35
+
36
+ export async function checkGradle(): Promise<{ gradleJavaHome?: string; gradleValid: boolean; filesChecked: string[]; issues: string[]; suggestedFixes?: string[] }> {
37
+ const issues: string[] = []
38
+ const filesChecked: string[] = []
39
+ const suggestedFixes: string[] = []
40
+ let gradleJavaHome: string | undefined
41
+
42
+ // 1) explicit env
43
+ if (process.env.GRADLE_JAVA_HOME) {
44
+ gradleJavaHome = process.env.GRADLE_JAVA_HOME
45
+ if (!javaBinExists(gradleJavaHome)) {
46
+ issues.push(`GRADLE_JAVA_HOME is set to '${gradleJavaHome}' but no java binary was found there`)
47
+ suggestedFixes.push('Unset GRADLE_JAVA_HOME or point it to a valid JDK (e.g., /Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home)')
48
+ }
49
+ }
50
+
51
+ // 2) user gradle.properties
52
+ const gradleUserHome = process.env.GRADLE_USER_HOME || path.join(os.homedir(), '.gradle')
53
+ const userProps = path.join(gradleUserHome, 'gradle.properties')
54
+ filesChecked.push(userProps)
55
+ try {
56
+ const props = readPropertiesFile(userProps)
57
+ if (props['org.gradle.java.home']) {
58
+ const p = props['org.gradle.java.home']
59
+ gradleJavaHome = gradleJavaHome || p
60
+ if (!javaBinExists(p)) {
61
+ issues.push(`org.gradle.java.home in ${userProps} points to '${p}' which does not look like a valid JDK`)
62
+ suggestedFixes.push(`Edit ${userProps} to remove or correct org.gradle.java.home`)
63
+ }
64
+ }
65
+ } catch { }
66
+
67
+ // 3) system gradle.properties
68
+ const systemProps = '/etc/gradle/gradle.properties'
69
+ filesChecked.push(systemProps)
70
+ try {
71
+ const props = readPropertiesFile(systemProps)
72
+ if (props['org.gradle.java.home']) {
73
+ const p = props['org.gradle.java.home']
74
+ gradleJavaHome = gradleJavaHome || p
75
+ if (!javaBinExists(p)) {
76
+ issues.push(`org.gradle.java.home in ${systemProps} points to '${p}' which does not look like a valid JDK`)
77
+ suggestedFixes.push(`Edit ${systemProps} to remove or correct org.gradle.java.home`)
78
+ }
79
+ }
80
+ } catch { }
81
+
82
+ // 4) GRADLE_HOME fallback
83
+ if (!gradleJavaHome && process.env.GRADLE_HOME) {
84
+ filesChecked.push(process.env.GRADLE_HOME)
85
+ if (javaBinExists(process.env.GRADLE_HOME)) {
86
+ gradleJavaHome = process.env.GRADLE_HOME
87
+ }
88
+ }
89
+
90
+ const gradleValid = !!gradleJavaHome && javaBinExists(gradleJavaHome)
91
+ if (!gradleJavaHome) {
92
+ // no explicit gradle java home detected — not an issue
93
+ } else if (!gradleValid) {
94
+ issues.push(`Detected org.gradle.java.home = '${gradleJavaHome}' but it is invalid`)
95
+ }
96
+
97
+ return { gradleJavaHome, gradleValid, filesChecked, issues, suggestedFixes }
98
+ }
@@ -1,11 +1,13 @@
1
1
  import { checkAndroid } from './android.js'
2
2
  import { checkIOS } from './ios.js'
3
+ import { checkGradle } from './gradle.js'
3
4
 
4
5
  export async function getSystemStatus() {
5
6
  try {
6
7
  const android = await checkAndroid()
7
8
  const ios = await checkIOS()
8
- const issues = [...android.issues, ...ios.issues]
9
+ const gradle = await checkGradle()
10
+ const issues = [...android.issues, ...ios.issues, ...(gradle.issues || [])]
9
11
 
10
12
  const success = issues.length === 0
11
13
  return {
@@ -19,7 +21,11 @@ export async function getSystemStatus() {
19
21
  issues,
20
22
  appInstalled: android.appInstalled,
21
23
  iosAvailable: ios.iosAvailable,
22
- iosDevices: ios.iosDevices
24
+ iosDevices: ios.iosDevices,
25
+ gradleJavaHome: gradle.gradleJavaHome,
26
+ gradleValid: gradle.gradleValid,
27
+ gradleFilesChecked: gradle.filesChecked,
28
+ gradleSuggestedFixes: gradle.suggestedFixes
23
29
  }
24
30
  } catch (e: unknown) {
25
31
  return { success: false, issues: ['Internal error: ' + (e instanceof Error ? e.message : String(e))] }
@@ -4,6 +4,7 @@ import path from 'path'
4
4
  import { detectJavaHome } from '../java.js'
5
5
  import { execCmd } from '../exec.js'
6
6
  import { spawnSync } from 'child_process'
7
+ import { checkGradle } from '../../system/gradle.js'
7
8
 
8
9
  function findInPath(cmd: string): string | null {
9
10
  try {
@@ -73,6 +74,14 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
73
74
  }
74
75
 
75
76
  const detectedJavaHome = await detectJavaHome().catch(() => undefined)
77
+ // Check for problematic org.gradle.java.home entries (env or properties) and avoid passing invalid values to Gradle
78
+ let gradleCheck
79
+ try {
80
+ gradleCheck = await checkGradle()
81
+ } catch {
82
+ gradleCheck = { gradleJavaHome: undefined, gradleValid: false, filesChecked: [], issues: [] }
83
+ }
84
+
76
85
  const env = Object.assign({}, process.env)
77
86
 
78
87
  // Ensure child processes can find Android platform-tools (adb, etc.) by
@@ -86,6 +95,7 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
86
95
  } catch (e: unknown) { console.debug(`[prepareGradle] error resolving adbPath: ${String(e)}`) }
87
96
 
88
97
  const pathParts: string[] = []
98
+ // Prefer a detected (validated) Java home from the system/IDE
89
99
  if (detectedJavaHome) {
90
100
  if (env.JAVA_HOME !== detectedJavaHome) {
91
101
  env.JAVA_HOME = detectedJavaHome
@@ -95,6 +105,20 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
95
105
  gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
96
106
  gradleArgs.push('--no-daemon')
97
107
  env.GRADLE_JAVA_HOME = detectedJavaHome
108
+ } else if (gradleCheck && gradleCheck.gradleJavaHome) {
109
+ // There's an org.gradle.java.home configured somewhere (env or gradle.properties)
110
+ if (gradleCheck.gradleValid) {
111
+ const p = gradleCheck.gradleJavaHome as string
112
+ const javaBin = path.join(p, 'bin')
113
+ if (!env.PATH || !env.PATH.includes(javaBin)) pathParts.push(javaBin)
114
+ gradleArgs.push(`-Dorg.gradle.java.home=${p}`)
115
+ gradleArgs.push('--no-daemon')
116
+ env.GRADLE_JAVA_HOME = p
117
+ } else {
118
+ // Invalid gradle java home detected: avoid passing it to Gradle and remove from spawn env
119
+ console.debug(`[prepareGradle] Invalid org.gradle.java.home detected (${gradleCheck.gradleJavaHome}); removing from spawn env to avoid Gradle error.`)
120
+ try { delete env.GRADLE_JAVA_HOME } catch { }
121
+ }
98
122
  }
99
123
 
100
124
  if (platformToolsDir) {
@@ -126,9 +150,11 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
126
150
  const spawnOpts: any = { cwd: projectPath, env }
127
151
  if (useWrapper) {
128
152
  try { await fsPromises.chmod(gradlewPath, 0o755) } catch (e: unknown) { console.debug('[prepareGradle] chmod failed for gradlew:', String(e)) }
153
+ // Execute the wrapper directly without a shell to avoid shell tokenization of args (spaces in paths)
129
154
  spawnOpts.shell = false
130
155
  } else {
131
- spawnOpts.shell = true
156
+ // Prefer executing gradle directly without invoking a shell to preserve argument boundaries
157
+ spawnOpts.shell = false
132
158
  }
133
159
 
134
160
  return { execCmd, gradleArgs, spawnOpts }
@@ -0,0 +1,41 @@
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('Unit: observe_until edge cases')
6
+
7
+ const origFind = (ToolsInteract as any).findElementHandler
8
+ const origFp = (Observe as any).ToolsObserve.getScreenFingerprintHandler
9
+
10
+ try {
11
+ // 1) Immediate absence should pass for match='absent'
12
+ (ToolsInteract as any).findElementHandler = async () => ({ found: false })
13
+ const r1 = await (ToolsInteract as any).observeUntilHandler({ type: 'ui', query: 'Nothing', timeoutMs: 2000, pollIntervalMs: 100, stability_ms: 200, match: 'absent', platform: 'android' })
14
+ console.log('Immediate absent test:', r1 && (r1 as any).success ? 'PASS' : 'FAIL', JSON.stringify({ poll_count: (r1 as any).poll_count, duration_ms: (r1 as any).duration_ms, stable_duration_ms: (r1 as any).stable_duration_ms, matchSource: (r1 as any).matchSource }, null, 2))
15
+
16
+ // 2) Boundary stability: condition becomes true and stays exactly long enough
17
+ // Use pollInterval 100ms and stability 300ms -> need ~3 consecutive trues
18
+ let seq2 = [false, true, true, true]
19
+ (ToolsInteract as any).findElementHandler = async () => ({ found: seq2.shift() ?? true })
20
+ const r2 = await (ToolsInteract as any).observeUntilHandler({ type: 'ui', query: 'Boundary', timeoutMs: 2000, pollIntervalMs: 100, stability_ms: 300, match: 'present', platform: 'android' })
21
+ console.log('Boundary stability test:', r2 && (r2 as any).success ? 'PASS' : 'FAIL', JSON.stringify({ poll_count: (r2 as any).poll_count, duration_ms: (r2 as any).duration_ms, stable_duration_ms: (r2 as any).stable_duration_ms, matchSource: (r2 as any).matchSource }, null, 2))
22
+
23
+ // 3) Long flicker that never stabilizes should timeout/fail
24
+ // Sequence toggles true/false repeatedly
25
+ let seq3 = [false, true, false, true, false, true, false]
26
+ (ToolsInteract as any).findElementHandler = async () => ({ found: seq3.shift() ?? false })
27
+ const r3 = await (ToolsInteract as any).observeUntilHandler({ type: 'ui', query: 'Flicker', timeoutMs: 1200, pollIntervalMs: 150, stability_ms: 400, match: 'present', platform: 'android' })
28
+ console.log('Long flicker timeout test:', !(r3 && (r3 as any).success) ? 'PASS' : 'FAIL', JSON.stringify({ poll_count: (r3 as any).poll_count, duration_ms: (r3 as any).duration_ms, stable_duration_ms: (r3 as any).stable_duration_ms, matchSource: (r3 as any).matchSource }, null, 2))
29
+
30
+ // 4) Very short stability requirement should pass quickly
31
+ (ToolsInteract as any).findElementHandler = async () => ({ found: true })
32
+ const r4 = await (ToolsInteract as any).observeUntilHandler({ type: 'ui', query: 'ShortStable', timeoutMs: 2000, pollIntervalMs: 200, stability_ms: 50, match: 'present', platform: 'android' })
33
+ console.log('Short stability test:', r4 && (r4 as any).success ? 'PASS' : 'FAIL', JSON.stringify({ poll_count: (r4 as any).poll_count, duration_ms: (r4 as any).duration_ms, stable_duration_ms: (r4 as any).stable_duration_ms, matchSource: (r4 as any).matchSource }, null, 2))
34
+
35
+ } finally {
36
+ (ToolsInteract as any).findElementHandler = origFind
37
+ (Observe as any).ToolsObserve.getScreenFingerprintHandler = origFp
38
+ }
39
+ }
40
+
41
+ run().catch(e=>{ console.error(e); process.exit(1) })
@@ -0,0 +1,30 @@
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('Unit: observe_until stability behavior')
6
+
7
+ const origFind = (ToolsInteract as any).findElementHandler
8
+ const origFp = (Observe as any).ToolsObserve.getScreenFingerprintHandler
9
+
10
+ try {
11
+ // Simulate UI flicker: present, absent, present, then stable
12
+ const seq = [false, true, false, true, true, true]
13
+ (ToolsInteract as any).findElementHandler = async () => ({ found: seq.shift() ?? true })
14
+
15
+ const res = await (ToolsInteract as any).observeUntilHandler({ type: 'ui', query: 'X', timeoutMs: 5000, pollIntervalMs: 100, stability_ms: 500, platform: 'android' })
16
+ const ok = res && (res as any).success
17
+ console.log('Flicker stability test:', ok ? 'PASS' : 'FAIL', JSON.stringify((res as any).telemetry || {}, null, 2))
18
+
19
+ // Simulate immediate stable presence
20
+ (ToolsInteract as any).findElementHandler = async () => ({ found: true })
21
+ const res2 = await (ToolsInteract as any).observeUntilHandler({ type: 'ui', query: 'Y', timeoutMs: 2000, pollIntervalMs: 100, stability_ms: 300, platform: 'android' })
22
+ console.log('Immediate stable test:', res2 && (res2 as any).success ? 'PASS' : 'FAIL', JSON.stringify((res2 as any).telemetry || {}, null, 2))
23
+
24
+ } finally {
25
+ (ToolsInteract as any).findElementHandler = origFind
26
+ (Observe as any).ToolsObserve.getScreenFingerprintHandler = origFp
27
+ }
28
+ }
29
+
30
+ run().catch(e=>{ console.error(e); process.exit(1) })
@@ -1,24 +0,0 @@
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
- })()