mobile-debug-mcp 0.20.1 → 0.21.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/android.js +0 -27
- package/dist/interact/index.js +145 -124
- package/dist/interact/ios.js +0 -26
- 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 +15 -25
- package/dist/system/gradle.js +4 -4
- package/dist/utils/android/utils.js +2 -2
- package/dist/utils/resolve-device.js +5 -0
- package/docs/CHANGELOG.md +7 -1
- package/docs/tools/interact.md +7 -27
- package/package.json +2 -2
- package/src/interact/android.ts +1 -32
- package/src/interact/index.ts +98 -78
- package/src/interact/ios.ts +1 -31
- package/src/server.ts +18 -25
- package/src/system/gradle.ts +4 -4
- package/src/utils/android/utils.ts +2 -2
- package/src/utils/resolve-device.ts +6 -0
- package/test/interact/device/run-real-test.ts +3 -19
- package/test/interact/unit/{observe_until.test.ts → wait_for_ui.test.ts} +6 -6
- package/test/observe/device/wait_for_element_real.ts +3 -80
- package/test/observe/unit/wait_for_element_mock.ts +2 -104
- package/test/observe/unit/wait_for_ui_edge_cases.test.ts +41 -0
- package/test/observe/unit/wait_for_ui_stability.test.ts +30 -0
- package/test/unit/index.ts +27 -15
- 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
|
|
|
@@ -40,12 +39,6 @@ export class ToolsInteract {
|
|
|
40
39
|
return { interact: interact as any, resolved, platform: effectivePlatform }
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
static async waitForElementHandler({ platform, text, timeout, deviceId }: { platform: 'android' | 'ios', text: string, timeout?: number, deviceId?: string }) {
|
|
44
|
-
const effectiveTimeout = timeout ?? 10000
|
|
45
|
-
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
46
|
-
return await interact.waitForElement(text, effectiveTimeout, resolved.id)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
42
|
static async tapHandler({ platform, x, y, deviceId }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string }) {
|
|
50
43
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
51
44
|
return await interact.tap(x, y, resolved.id)
|
|
@@ -225,6 +218,11 @@ export class ToolsInteract {
|
|
|
225
218
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal }
|
|
226
219
|
}
|
|
227
220
|
|
|
221
|
+
static async waitForUIHandler({ 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 }) {
|
|
222
|
+
// Backwards-compatible wrapper that delegates to the core waitForUICore implementation
|
|
223
|
+
return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
|
|
224
|
+
}
|
|
225
|
+
|
|
228
226
|
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
|
|
229
227
|
const start = Date.now()
|
|
230
228
|
let lastFingerprint: string | null = null
|
|
@@ -262,76 +260,94 @@ export class ToolsInteract {
|
|
|
262
260
|
return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
|
|
263
261
|
}
|
|
264
262
|
|
|
265
|
-
static async
|
|
263
|
+
static async waitForUICore({ 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
264
|
const start = Date.now()
|
|
267
265
|
const deadline = start + (timeoutMs || 0)
|
|
268
266
|
const q = (query === null || query === undefined) ? '' : String(query)
|
|
269
267
|
|
|
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 }
|
|
268
|
+
// Clamp polling interval to 250-500ms for consistent behavior
|
|
269
|
+
const pollInterval = Math.max(250, Math.min(pollIntervalMs || 300, 500))
|
|
276
270
|
|
|
277
|
-
//
|
|
271
|
+
// Baseline state (fetch in parallel but bound to short timeouts so observation starts promptly)
|
|
272
|
+
let initialFingerprint: string | null = null
|
|
278
273
|
let baselineLastLine: string | null = null
|
|
279
274
|
try {
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
275
|
+
const fpPromise = ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as Promise<ScreenFingerprintResponse | null>
|
|
276
|
+
const logsPromise = ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as Promise<any>
|
|
277
|
+
const withTimeout = (p: Promise<any>, ms: number) => Promise.race([p, new Promise(resolve => setTimeout(() => resolve(null), ms))])
|
|
278
|
+
const [fpRes, gl] = await Promise.all([withTimeout(fpPromise, 300), withTimeout(logsPromise, 500)])
|
|
279
|
+
if (fpRes && typeof fpRes === 'object') initialFingerprint = (fpRes as ScreenFingerprintResponse).fingerprint ?? null
|
|
280
|
+
if (gl) {
|
|
281
|
+
const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
|
|
282
|
+
baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
|
|
283
|
+
}
|
|
283
284
|
} catch (err) {
|
|
284
|
-
|
|
285
|
-
try { console.warn('observeUntil: failed to get baseline logs (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
|
|
285
|
+
try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
|
|
288
|
+
// Network-based waiting removed. Rely on UI and screen fingerprints for determinism.
|
|
289
289
|
let lastChangeAt = Date.now()
|
|
290
290
|
let prevFingerprint = initialFingerprint
|
|
291
291
|
|
|
292
292
|
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
293
293
|
|
|
294
|
+
// Optional initial observation delay requested by caller
|
|
295
|
+
if (typeof observationDelayMs === 'number' && observationDelayMs > 0) {
|
|
296
|
+
try { console.log(`waitForUI: delaying observation for ${observationDelayMs}ms`) } catch { }
|
|
297
|
+
await sleep(observationDelayMs)
|
|
298
|
+
}
|
|
299
|
+
|
|
294
300
|
// Telemetry
|
|
295
301
|
let pollCount = 0
|
|
296
|
-
let
|
|
302
|
+
let matchedAt: number | null = null
|
|
303
|
+
let lastObservedState: boolean | null = null
|
|
304
|
+
let stableDuration = 0
|
|
297
305
|
let matchSource: string | null = null
|
|
298
306
|
|
|
299
307
|
while (Date.now() <= deadline) {
|
|
300
308
|
pollCount++
|
|
301
|
-
|
|
309
|
+
const now = Date.now()
|
|
310
|
+
// Evaluate condition per type
|
|
302
311
|
if (type === 'ui') {
|
|
303
|
-
// fast findElement with short timeout to avoid blocking
|
|
304
312
|
try {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
313
|
+
// Prefer using the public findElementHandler which tests can override. This avoids relying
|
|
314
|
+
// on resolveObserve/getUITree for unit tests which may not have devices available.
|
|
315
|
+
try {
|
|
316
|
+
const findRes = await (ToolsInteract as any).findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, pollInterval), platform, deviceId })
|
|
317
|
+
const isPresent = !!(findRes && (findRes as any).found)
|
|
318
|
+
const conditionTrue = (match === 'present') ? isPresent : !isPresent
|
|
319
|
+
if (conditionTrue) {
|
|
320
|
+
if (matchedAt === null) matchedAt = Date.now()
|
|
321
|
+
stableDuration = Date.now() - (matchedAt as number)
|
|
322
|
+
lastObservedState = true
|
|
323
|
+
if (stableDuration >= stability_ms) {
|
|
324
|
+
matchSource = 'ui-find'
|
|
325
|
+
const element = isPresent ? (findRes as any).element : null
|
|
326
|
+
const now2 = Date.now()
|
|
327
|
+
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 }
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
matchedAt = null
|
|
331
|
+
stableDuration = 0
|
|
332
|
+
lastObservedState = false
|
|
333
|
+
}
|
|
334
|
+
} catch (err) { console.error('waitForUI(ui) find error:', err) }
|
|
335
|
+
} catch (err) { console.error('waitForUI(ui) outer error:', err) }
|
|
317
336
|
} else if (type === 'log') {
|
|
318
337
|
try {
|
|
319
|
-
//
|
|
338
|
+
// Logs: presence semantics only (match 'present'). Stability not applicable (immediate)
|
|
320
339
|
const stream = await ToolsObserve.readLogStreamHandler({ platform, sessionId: 'default', limit: 200 }) as any
|
|
321
340
|
const entries = (stream && Array.isArray(stream.entries)) ? stream.entries : []
|
|
322
341
|
for (const ent of entries) {
|
|
323
342
|
const msg = ent && (ent.message || ent.msg || ent) ? (ent.message || ent.msg || ent) : ''
|
|
324
343
|
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 } }
|
|
344
|
+
const now2 = Date.now()
|
|
345
|
+
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
346
|
}
|
|
329
347
|
}
|
|
330
348
|
|
|
331
|
-
// Fallback to snapshot logs
|
|
332
349
|
const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
|
|
333
350
|
const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
|
|
334
|
-
// Only consider new lines after baselineLastLine when possible
|
|
335
351
|
let startIndex = 0
|
|
336
352
|
if (baselineLastLine) {
|
|
337
353
|
const idx = logsArr.lastIndexOf(baselineLastLine)
|
|
@@ -340,35 +356,33 @@ export class ToolsInteract {
|
|
|
340
356
|
for (let i = startIndex; i < logsArr.length; i++) {
|
|
341
357
|
const line = logsArr[i]
|
|
342
358
|
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 } }
|
|
359
|
+
const now2 = Date.now()
|
|
360
|
+
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
361
|
}
|
|
347
362
|
}
|
|
348
|
-
} catch (err) { console.error('
|
|
363
|
+
} catch (err) { console.error('waitForUI(log) error:', err) }
|
|
349
364
|
} else if (type === 'screen') {
|
|
350
365
|
try {
|
|
351
366
|
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
352
367
|
const fp = fpRes?.fingerprint ?? null
|
|
353
368
|
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
|
|
369
|
+
// when screen changed, require stability_ms where fingerprint remains the same
|
|
370
|
+
if (matchedAt === null) matchedAt = now
|
|
371
|
+
const confirmFp = (await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null)?.fingerprint ?? null
|
|
372
|
+
if (confirmFp === fp) {
|
|
373
|
+
stableDuration = Date.now() - (matchedAt as number)
|
|
374
|
+
lastObservedState = true
|
|
375
|
+
if (stableDuration >= stability_ms) {
|
|
376
|
+
const now2 = Date.now()
|
|
377
|
+
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 }
|
|
378
|
+
}
|
|
365
379
|
} else {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
380
|
+
matchedAt = null
|
|
381
|
+
stableDuration = 0
|
|
382
|
+
lastObservedState = false
|
|
369
383
|
}
|
|
370
384
|
}
|
|
371
|
-
} catch (err) { console.error('
|
|
385
|
+
} catch (err) { console.error('waitForUI(screen) error:', err) }
|
|
372
386
|
} else if (type === 'idle') {
|
|
373
387
|
try {
|
|
374
388
|
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
@@ -376,33 +390,39 @@ export class ToolsInteract {
|
|
|
376
390
|
if (fp !== prevFingerprint) {
|
|
377
391
|
prevFingerprint = fp
|
|
378
392
|
lastChangeAt = Date.now()
|
|
393
|
+
matchedAt = null
|
|
394
|
+
stableDuration = 0
|
|
395
|
+
lastObservedState = false
|
|
379
396
|
} else {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
397
|
+
const idleMs = Date.now() - lastChangeAt
|
|
398
|
+
lastObservedState = true
|
|
399
|
+
if (idleMs >= stability_ms) {
|
|
400
|
+
const now2 = Date.now()
|
|
401
|
+
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
402
|
}
|
|
385
403
|
}
|
|
386
|
-
} catch (err) { console.error('
|
|
404
|
+
} catch (err) { console.error('waitForUI(idle) error:', err) }
|
|
387
405
|
}
|
|
388
|
-
} catch (err) {
|
|
389
|
-
console.error('observeUntil: unexpected error', err)
|
|
390
|
-
}
|
|
391
406
|
|
|
392
407
|
// Respect poll interval and avoid tight loop
|
|
393
|
-
await sleep(
|
|
408
|
+
await sleep(pollInterval)
|
|
394
409
|
}
|
|
395
410
|
|
|
396
|
-
// On timeout, capture a failure snapshot to aid debugging (best-effort)
|
|
411
|
+
// On timeout, optionally capture a failure snapshot to aid debugging (best-effort)
|
|
397
412
|
let snapshot: any = null
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
413
|
+
if (includeSnapshotOnFailure) {
|
|
414
|
+
try {
|
|
415
|
+
// Use dynamic import to avoid circular-initialization issues where the ToolsObserve
|
|
416
|
+
// binding captured earlier may not reflect test-time overrides. Importing at call
|
|
417
|
+
// time ensures the latest exported ToolsObserve object is used.
|
|
418
|
+
const Obs = await import('../observe/index.js')
|
|
419
|
+
snapshot = await (Obs as any).ToolsObserve.captureDebugSnapshotHandler({ reason: `wait_for_ui timeout for ${type}`, includeLogs: true, platform, deviceId })
|
|
420
|
+
} catch (err) {
|
|
421
|
+
snapshot = { error: err instanceof Error ? err.message : String(err) }
|
|
422
|
+
}
|
|
402
423
|
}
|
|
403
424
|
|
|
404
425
|
const elapsed = Date.now() - start
|
|
405
|
-
return { success: false,
|
|
406
|
-
}
|
|
407
|
-
|
|
426
|
+
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 }
|
|
427
|
+
}
|
|
408
428
|
}
|
package/src/interact/ios.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
|
-
import {
|
|
2
|
+
import { TapResponse, SwipeResponse } from "../types.js"
|
|
3
3
|
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
4
4
|
import { iOSObserve } from "../observe/index.js"
|
|
5
5
|
import { scrollToElementShared } from "../utils/ui/index.js"
|
|
@@ -7,36 +7,6 @@ import { scrollToElementShared } from "../utils/ui/index.js"
|
|
|
7
7
|
export class iOSInteract {
|
|
8
8
|
private observe = new iOSObserve();
|
|
9
9
|
|
|
10
|
-
async waitForElement(text: string, timeout: number, deviceId: string = "booted"): Promise<WaitForElementResponse> {
|
|
11
|
-
const device = await getIOSDeviceMetadata(deviceId);
|
|
12
|
-
const startTime = Date.now();
|
|
13
|
-
|
|
14
|
-
while (Date.now() - startTime < timeout) {
|
|
15
|
-
try {
|
|
16
|
-
const tree = await this.observe.getUITree(deviceId);
|
|
17
|
-
|
|
18
|
-
if (tree.error) {
|
|
19
|
-
return { device, found: false, error: tree.error };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const element = tree.elements.find(e => e.text === text);
|
|
23
|
-
if (element) {
|
|
24
|
-
return { device, found: true, element };
|
|
25
|
-
}
|
|
26
|
-
} catch (e) {
|
|
27
|
-
// Ignore errors during polling and retry
|
|
28
|
-
console.error("Error polling UI tree:", e);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const elapsed = Date.now() - startTime;
|
|
32
|
-
const remaining = timeout - elapsed;
|
|
33
|
-
if (remaining <= 0) break;
|
|
34
|
-
|
|
35
|
-
await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
|
|
36
|
-
}
|
|
37
|
-
return { device, found: false };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
10
|
async tap(x: number, y: number, deviceId: string = "booted"): Promise<TapResponse> {
|
|
41
11
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
42
12
|
|
package/src/server.ts
CHANGED
|
@@ -340,33 +340,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
340
340
|
}
|
|
341
341
|
},
|
|
342
342
|
{
|
|
343
|
-
name: "
|
|
344
|
-
description: "Wait
|
|
343
|
+
name: "wait_for_ui",
|
|
344
|
+
description: "Wait for a UI/log/screen/idle condition with a stability window before returning success.",
|
|
345
345
|
inputSchema: {
|
|
346
346
|
type: "object",
|
|
347
347
|
properties: {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
},
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
type: "number",
|
|
359
|
-
description: "Max wait time in ms (default 10000)",
|
|
360
|
-
default: 10000
|
|
361
|
-
},
|
|
362
|
-
deviceId: {
|
|
363
|
-
type: "string",
|
|
364
|
-
description: "Device Serial/UDID. Defaults to connected/booted device."
|
|
365
|
-
}
|
|
366
|
-
},
|
|
367
|
-
required: ["platform", "text"]
|
|
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
|
+
}
|
|
368
358
|
}
|
|
369
359
|
},
|
|
360
|
+
|
|
361
|
+
|
|
370
362
|
{
|
|
371
363
|
name: "find_element",
|
|
372
364
|
description: "Find a UI element by semantic query (text, content-desc, resource-id, class). Returns best match.",
|
|
@@ -674,9 +666,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
674
666
|
return wrapResponse(res)
|
|
675
667
|
}
|
|
676
668
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const
|
|
669
|
+
|
|
670
|
+
if (name === "wait_for_ui") {
|
|
671
|
+
const { type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId } = (args || {}) as any
|
|
672
|
+
const res = await ToolsInteract.waitForUIHandler({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
|
|
680
673
|
return wrapResponse(res)
|
|
681
674
|
}
|
|
682
675
|
|
package/src/system/gradle.ts
CHANGED
|
@@ -17,7 +17,7 @@ function readPropertiesFile(p: string): Record<string,string> {
|
|
|
17
17
|
out[k] = v
|
|
18
18
|
}
|
|
19
19
|
return out
|
|
20
|
-
} catch
|
|
20
|
+
} catch {
|
|
21
21
|
return {}
|
|
22
22
|
}
|
|
23
23
|
}
|
|
@@ -30,7 +30,7 @@ function javaBinExists(p?: string): boolean {
|
|
|
30
30
|
const alt = path.join(p, 'Contents', 'Home', 'bin', 'java')
|
|
31
31
|
if (existsSync(alt)) return true
|
|
32
32
|
return false
|
|
33
|
-
} catch
|
|
33
|
+
} catch { return false }
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export async function checkGradle(): Promise<{ gradleJavaHome?: string; gradleValid: boolean; filesChecked: string[]; issues: string[]; suggestedFixes?: string[] }> {
|
|
@@ -62,7 +62,7 @@ export async function checkGradle(): Promise<{ gradleJavaHome?: string; gradleVa
|
|
|
62
62
|
suggestedFixes.push(`Edit ${userProps} to remove or correct org.gradle.java.home`)
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
-
} catch
|
|
65
|
+
} catch { }
|
|
66
66
|
|
|
67
67
|
// 3) system gradle.properties
|
|
68
68
|
const systemProps = '/etc/gradle/gradle.properties'
|
|
@@ -77,7 +77,7 @@ export async function checkGradle(): Promise<{ gradleJavaHome?: string; gradleVa
|
|
|
77
77
|
suggestedFixes.push(`Edit ${systemProps} to remove or correct org.gradle.java.home`)
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
} catch
|
|
80
|
+
} catch { }
|
|
81
81
|
|
|
82
82
|
// 4) GRADLE_HOME fallback
|
|
83
83
|
if (!gradleJavaHome && process.env.GRADLE_HOME) {
|
|
@@ -78,7 +78,7 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
|
|
|
78
78
|
let gradleCheck
|
|
79
79
|
try {
|
|
80
80
|
gradleCheck = await checkGradle()
|
|
81
|
-
} catch
|
|
81
|
+
} catch {
|
|
82
82
|
gradleCheck = { gradleJavaHome: undefined, gradleValid: false, filesChecked: [], issues: [] }
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -117,7 +117,7 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
|
|
|
117
117
|
} else {
|
|
118
118
|
// Invalid gradle java home detected: avoid passing it to Gradle and remove from spawn env
|
|
119
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
|
|
120
|
+
try { delete env.GRADLE_JAVA_HOME } catch { }
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
@@ -32,6 +32,12 @@ export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceI
|
|
|
32
32
|
const { platform, appId, prefer, deviceId } = opts
|
|
33
33
|
const devices = await listDevices(platform, appId)
|
|
34
34
|
|
|
35
|
+
// During unit tests (no adb/xcrun available), provide a lightweight mock device so
|
|
36
|
+
// the observe/interact unit tests can run without real devices.
|
|
37
|
+
if ((!devices || devices.length === 0) && (process.env.NODE_ENV === 'test' || process.env.MCP_TEST_MOCK_DEVICES === '1')) {
|
|
38
|
+
return { id: 'mock', platform: platform || 'android', osVersion: '12', model: 'Pixel', simulator: true } as DeviceInfo
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
if (deviceId) {
|
|
36
42
|
const found = devices.find(d => d.id === deviceId)
|
|
37
43
|
if (!found) throw new Error(`Device '${deviceId}' not found for platform ${platform}`)
|
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
-
const __dirname = path.dirname(__filename);
|
|
6
|
-
const ADB_PATH = process.env.ADB_PATH || process.env.ADB || 'adb';
|
|
7
|
-
const TEST_FILE = path.join(__dirname, 'wait_for_element_real.ts');
|
|
8
|
-
|
|
9
|
-
const childEnv = { ...process.env, ADB_PATH };
|
|
10
|
-
const runner = process.env.RUNNER || 'npx';
|
|
11
|
-
const runnerArgs = ['tsx', TEST_FILE];
|
|
12
|
-
|
|
13
|
-
const child = spawn(runner, runnerArgs, {
|
|
14
|
-
env: childEnv,
|
|
15
|
-
stdio: 'inherit'
|
|
16
|
-
});
|
|
17
|
-
child.on('exit', (code) => {
|
|
18
|
-
process.exit(code || 0);
|
|
19
|
-
});
|
|
1
|
+
// wait_for_element device runner removed
|
|
2
|
+
console.log('wait_for_element device runner removed');
|
|
3
|
+
process.exit(0);
|
|
@@ -2,7 +2,7 @@ import { ToolsInteract } from '../../../src/interact/index.js'
|
|
|
2
2
|
import * as Observe from '../../../src/observe/index.js'
|
|
3
3
|
|
|
4
4
|
async function runTests() {
|
|
5
|
-
console.log('Starting
|
|
5
|
+
console.log('Starting wait_for_ui unit tests...')
|
|
6
6
|
|
|
7
7
|
const origFind = (ToolsInteract as any).findElementHandler
|
|
8
8
|
const origReadLog = (Observe as any).ToolsObserve.readLogStreamHandler
|
|
@@ -17,7 +17,7 @@ async function runTests() {
|
|
|
17
17
|
;(Observe as any).ToolsObserve.captureDebugSnapshotHandler = async ({ reason }: any) => ({ reason, fingerprint: 'snap-123', ui_tree: null, logs: [] })
|
|
18
18
|
// make findElement always fail
|
|
19
19
|
(ToolsInteract as any).findElementHandler = async () => ({ found: false })
|
|
20
|
-
const resTimeout = await ToolsInteract.
|
|
20
|
+
const resTimeout = await ToolsInteract.waitForUIHandler({ type: 'ui', query: 'WillNeverExist', timeoutMs: 500, pollIntervalMs: 100, platform: 'android' })
|
|
21
21
|
const okTimeout = resTimeout && !(resTimeout as any).success && (resTimeout as any).snapshot && (resTimeout as any).snapshot.fingerprint === 'snap-123' && (resTimeout as any).telemetry && (resTimeout as any).telemetry.pollCount > 0
|
|
22
22
|
console.log('Timeout Snapshot Test:', okTimeout ? 'PASS' : 'FAIL', JSON.stringify((resTimeout as any).telemetry || {}, null, 2))
|
|
23
23
|
;(Observe as any).ToolsObserve.captureDebugSnapshotHandler = origCapture
|
|
@@ -31,7 +31,7 @@ async function runTests() {
|
|
|
31
31
|
return { found: false }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const resUi = await ToolsInteract.
|
|
34
|
+
const resUi = await ToolsInteract.waitForUIHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
|
|
35
35
|
const okUi = resUi && (resUi as any).success && (resUi as any).telemetry && (resUi as any).telemetry.pollCount > 0 && (resUi as any).telemetry.timeToMatch >= 0
|
|
36
36
|
console.log('UI Test:', okUi ? 'PASS' : 'FAIL', JSON.stringify((resUi as any).telemetry || {}, null, 2))
|
|
37
37
|
|
|
@@ -44,21 +44,21 @@ async function runTests() {
|
|
|
44
44
|
return { device: {}, logs: ['INFO start', 'ERROR Exception occurred', 'Server: Boom'] }
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
const resLog = await ToolsInteract.
|
|
47
|
+
const resLog = await ToolsInteract.waitForUIHandler({ type: 'log', query: 'Server', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
|
|
48
48
|
const okLog = resLog && (resLog as any).success && (resLog as any).telemetry && (resLog as any).telemetry.pollCount > 0 && (resLog as any).telemetry.matchSource === 'log-snapshot'
|
|
49
49
|
console.log('Log Test:', okLog ? 'PASS' : 'FAIL', JSON.stringify((resLog as any).telemetry || {}, null, 2))
|
|
50
50
|
|
|
51
51
|
// Screen condition: fingerprint changes after a few polls
|
|
52
52
|
let seq = ['A', 'A', 'B']
|
|
53
53
|
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq.length ? seq.shift() : null })
|
|
54
|
-
const resScreen = await ToolsInteract.
|
|
54
|
+
const resScreen = await ToolsInteract.waitForUIHandler({ type: 'screen', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
|
|
55
55
|
const okScreen = resScreen && (resScreen as any).success && (resScreen as any).telemetry && (resScreen as any).telemetry.matchSource === 'screen-fingerprint'
|
|
56
56
|
console.log('Screen Test:', okScreen ? 'PASS' : 'FAIL', JSON.stringify((resScreen as any).telemetry || {}, null, 2))
|
|
57
57
|
|
|
58
58
|
// Idle condition: stable fingerprints observed
|
|
59
59
|
let idleSeq = ['X', 'X', 'X']
|
|
60
60
|
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: idleSeq.length ? idleSeq.shift() : 'X' })
|
|
61
|
-
const resIdle = await ToolsInteract.
|
|
61
|
+
const resIdle = await ToolsInteract.waitForUIHandler({ type: 'idle', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
|
|
62
62
|
const okIdle = resIdle && (resIdle as any).success && (resIdle as any).telemetry && (resIdle as any).telemetry.matchSource === 'idle-stable'
|
|
63
63
|
console.log('Idle Test:', okIdle ? 'PASS' : 'FAIL', JSON.stringify((resIdle as any).telemetry || {}, null, 2))
|
|
64
64
|
|
|
@@ -1,80 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// Usage: npx tsx test/wait_for_element_real.ts <deviceId> <appId>
|
|
5
|
-
const args = process.argv.slice(2);
|
|
6
|
-
const DEVICE_ID = args[0] || process.env.DEVICE_ID;
|
|
7
|
-
const APP_ID = args[1] || process.env.APP_ID;
|
|
8
|
-
|
|
9
|
-
if (!DEVICE_ID || !APP_ID) {
|
|
10
|
-
console.error("Usage: npx tsx test/wait_for_element_real.ts <deviceId> <appId> or set DEVICE_ID and APP_ID env vars");
|
|
11
|
-
process.exit(1);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function runRealTest() {
|
|
15
|
-
console.log(`Connecting to device ${DEVICE_ID}...`);
|
|
16
|
-
const interact = new AndroidInteract();
|
|
17
|
-
const observe = new AndroidObserve();
|
|
18
|
-
try {
|
|
19
|
-
console.log(`\nStarting app ${APP_ID}...`);
|
|
20
|
-
await interact.startApp(APP_ID, DEVICE_ID);
|
|
21
|
-
console.log("Waiting 3s for app to render...");
|
|
22
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
23
|
-
|
|
24
|
-
console.log("\nFetching UI Tree to find a target text...");
|
|
25
|
-
const tree = await observe.getUITree(DEVICE_ID);
|
|
26
|
-
if (tree.error) {
|
|
27
|
-
console.error("Failed to get UI Tree:", tree.error);
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const targetElement = tree.elements.find(e => e.text && e.text.length > 0 && e.visible);
|
|
32
|
-
if (!targetElement || !targetElement.text) {
|
|
33
|
-
console.warn("No visible text elements found on screen to test with.");
|
|
34
|
-
console.log("Elements found:", tree.elements.length);
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const targetText = targetElement.text;
|
|
39
|
-
console.log(`Found target element: "${targetText}"`);
|
|
40
|
-
|
|
41
|
-
console.log(`\nTest 1: Waiting for existing element "${targetText}" (should succeed)...`);
|
|
42
|
-
const start1 = Date.now();
|
|
43
|
-
const result1 = await interact.waitForElement(targetText, 5000, DEVICE_ID);
|
|
44
|
-
const elapsed1 = Date.now() - start1;
|
|
45
|
-
console.log(`Result: ${result1.found ? "PASS" : "FAIL"}`);
|
|
46
|
-
console.log(`Found Element: ${result1.element?.text}`);
|
|
47
|
-
console.log(`Time taken: ${elapsed1}ms`);
|
|
48
|
-
|
|
49
|
-
const missingText = "THIS_TEXT_SHOULD_NOT_EXIST_XYZ_123";
|
|
50
|
-
console.log(`\nTest 2: Waiting for missing element "${missingText}" (should timeout)...`);
|
|
51
|
-
const start2 = Date.now();
|
|
52
|
-
const result2 = await interact.waitForElement(missingText, 2000, DEVICE_ID);
|
|
53
|
-
const elapsed2 = Date.now() - start2;
|
|
54
|
-
console.log(`Result: ${!result2.found ? "PASS" : "FAIL"}`);
|
|
55
|
-
console.log(`Found: ${result2.found}`);
|
|
56
|
-
console.log(`Time taken: ${elapsed2}ms (expected ~2000ms)`);
|
|
57
|
-
|
|
58
|
-
console.log(`\nTest 3: Found after polling`);
|
|
59
|
-
let calls = 0;
|
|
60
|
-
AndroidObserve.prototype.getUITree = async function() {
|
|
61
|
-
calls++;
|
|
62
|
-
if (calls < 3) {
|
|
63
|
-
return { device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true }, screen: "", resolution: { width: 1080, height: 1920 }, elements: [] };
|
|
64
|
-
}
|
|
65
|
-
return { device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true }, screen: "", resolution: { width: 1080, height: 1920 }, elements: [{ text: "Target", type: "Button", contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [0,0,100,100], resourceId: null }] };
|
|
66
|
-
} as any;
|
|
67
|
-
|
|
68
|
-
const start3 = Date.now();
|
|
69
|
-
const result3 = await interact.waitForElement("Target", 2000, DEVICE_ID);
|
|
70
|
-
const elapsed3 = Date.now() - start3;
|
|
71
|
-
console.log(`Result: ${result3.found ? "PASS" : "FAIL"}`);
|
|
72
|
-
console.log(`Calls: ${calls} ${calls === 3 ? "PASS" : "FAIL"}`);
|
|
73
|
-
console.log(`Elapsed time (should be >= 1000ms): ${elapsed3} ${elapsed3 >= 1000 ? "PASS" : "FAIL"}`);
|
|
74
|
-
|
|
75
|
-
} catch {
|
|
76
|
-
console.error("Test failed with error:", error);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
runRealTest();
|
|
1
|
+
// wait_for_element device runner removed
|
|
2
|
+
console.log('wait_for_element device test removed');
|
|
3
|
+
process.exit(0);
|