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.
- package/README.md +7 -5
- package/dist/interact/index.js +154 -118
- package/dist/manage/android.js +5 -2
- package/dist/scripts/capture_ui_after_tap.mjs +43 -0
- package/dist/scripts/check_play_observe.mjs +18 -0
- package/dist/scripts/check_play_substring.mjs +38 -0
- package/dist/scripts/dump_ui_tree.mjs +20 -0
- package/dist/scripts/observe-test.mjs +32 -0
- package/dist/scripts/press_and_observe.mjs +90 -0
- package/dist/scripts/press_and_wait_ui.mjs +85 -0
- package/dist/scripts/test_generate_and_wait.mjs +123 -0
- package/dist/server.js +18 -0
- package/dist/system/gradle.js +101 -0
- package/dist/system/index.js +8 -2
- package/dist/utils/android/utils.js +33 -1
- package/docs/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/interact/index.ts +96 -68
- package/src/manage/android.ts +5 -1
- package/src/server.ts +20 -0
- package/src/system/gradle.ts +98 -0
- package/src/system/index.ts +8 -2
- package/src/utils/android/utils.ts +27 -1
- package/test/observe/unit/observe_until_edge_cases.test.ts +41 -0
- package/test/observe/unit/observe_until_stability.test.ts +30 -0
- package/test/interact/device/observe_until_device.ts +0 -24
package/src/interact/index.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
271
|
-
|
|
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
|
-
//
|
|
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
|
|
281
|
-
const
|
|
282
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
}
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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(
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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,
|
|
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
|
}
|
package/src/manage/android.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/system/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
})()
|