mobile-debug-mcp 0.20.1 → 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.
@@ -0,0 +1,85 @@
1
+ import { ToolsObserve } from '../observe/index.js'
2
+ import { ToolsInteract } from '../interact/index.js'
3
+
4
+ async function main() {
5
+ const bundle = 'com.ideamechanics.modul8'
6
+ const sessionId = 'press-ui-test'
7
+ console.log('Starting log stream for', bundle)
8
+ try {
9
+ const platform = process.argv.includes('--platform') ? process.argv[process.argv.indexOf('--platform')+1] : 'android'
10
+ const pkg = bundle
11
+ const sessionOpts = { platform, packageName: pkg, sessionId }
12
+ const start = await ToolsObserve.startLogStreamHandler(sessionOpts)
13
+ console.log('startLogStream result:', JSON.stringify(start))
14
+ } catch (e) {
15
+ console.error('startLogStream failed:', e instanceof Error ? e.message : String(e))
16
+ }
17
+
18
+ console.log('\nAttempting to find and tap Generate Session...')
19
+ let el = null
20
+ try {
21
+ const platform = process.argv.includes('--platform') ? process.argv[process.argv.indexOf('--platform')+1] : 'android'
22
+ const found = await ToolsInteract.findElementHandler({ query: 'Generate Session', exact: platform === 'android', timeoutMs: 3000, platform })
23
+ const foundFlag = found && typeof (found).found !== 'undefined' ? found.found : false
24
+ console.log('findElementHandler:', foundFlag ? 'found' : 'not found')
25
+ if (foundFlag) {
26
+ el = found.element
27
+ console.log('Matched element:', JSON.stringify(el))
28
+ }
29
+ } catch (e) { console.error('findElementHandler err:', e) }
30
+
31
+ try {
32
+ const platform = process.argv.includes('--platform') ? process.argv[process.argv.indexOf('--platform')+1] : 'android'
33
+ const device = process.argv.includes('--device') ? process.argv[process.argv.indexOf('--device')+1] : undefined
34
+
35
+ // Strict foreground check: ensure the package is in foreground before tapping
36
+ try {
37
+ const fg = await ToolsInteract.get_current_activity ? await ToolsInteract.get_current_activity({ platform, deviceId: device }) : null
38
+ console.log('Foreground activity info:', fg)
39
+ if (fg && fg.activity && !String(fg.activity).includes('modul8')) {
40
+ console.error('App is not foreground. Aborting tap.')
41
+ process.exit(2)
42
+ }
43
+ } catch (e) { console.warn('Foreground check failed:', e) }
44
+
45
+ if (el && el.tapCoordinates) {
46
+ console.log('Tapping matched element at', JSON.stringify(el.tapCoordinates))
47
+ await ToolsInteract.tapHandler({ platform, x: el.tapCoordinates.x, y: el.tapCoordinates.y, deviceId: device })
48
+ } else {
49
+ console.log('Element not found, tapping center fallback')
50
+ await ToolsInteract.tapHandler({ platform, x: 200, y: 400, deviceId: device })
51
+ }
52
+ } catch (e) { console.error('Tap failed:', e) }
53
+
54
+ console.log('\nNow waiting for "Generating session" to disappear (timeout 30s)')
55
+ const start = Date.now()
56
+ const deadline = start + (parseInt(process.argv.includes('--timeout') ? process.argv[process.argv.indexOf('--timeout')+1] : '30000'))
57
+ let poll = 0
58
+ let disappeared = false
59
+ const platformArg = process.argv.includes('--platform') ? process.argv[process.argv.indexOf('--platform')+1] : 'android'
60
+ const deviceArg = process.argv.includes('--device') ? process.argv[process.argv.indexOf('--device')+1] : undefined
61
+ while (Date.now() <= deadline) {
62
+ poll++
63
+ try {
64
+ const f = await ToolsInteract.findElementHandler({ query: 'Generating session', exact: false, timeoutMs: 1000, platform: platformArg, deviceId: deviceArg })
65
+ const exists = f && typeof f.found !== 'undefined' ? f.found : false
66
+ console.log(`poll ${poll}: generating exists=${exists}`)
67
+ if (!exists) { disappeared = true; break }
68
+ } catch (e) { console.error('findElement err:', e) }
69
+ await new Promise(r => setTimeout(r, 500))
70
+ }
71
+
72
+ const elapsed = Date.now()-start
73
+ console.log('Result: disappeared=', disappeared, 'polls=', poll, 'elapsedMs=', elapsed)
74
+
75
+ // Platform-specific post-conditions
76
+
77
+
78
+ console.log('\nFinal Result: success=', disappeared)
79
+
80
+ console.log('\nStopping log stream')
81
+ try { const stop = await ToolsObserve.stopLogStreamHandler({ platform: 'ios', sessionId }); console.log('stopLogStream', stop) } catch (e) { console.error('stop failed', e) }
82
+ process.exit(disappeared ? 0 : 2)
83
+ }
84
+
85
+ main().catch(e=>{ console.error(e); process.exit(1) })
@@ -0,0 +1,123 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { ToolsObserve } from '../observe/index.js'
4
+ import { ToolsInteract } from '../interact/index.js'
5
+
6
+ const argv = process.argv.slice(2)
7
+ const opts = { platform: 'android', deviceId: undefined, timeoutMs: 180000, stabilityMs: 1000, pollIntervalMs: 500, saveDir: '/tmp' }
8
+ for (let i = 0; i < argv.length; i++) {
9
+ if (argv[i] === '--platform') opts.platform = argv[++i]
10
+ else if (argv[i] === '--deviceId') opts.deviceId = argv[++i]
11
+ else if (argv[i] === '--timeout') opts.timeoutMs = Number(argv[++i])
12
+ else if (argv[i] === '--stabilityMs') opts.stabilityMs = Number(argv[++i])
13
+ else if (argv[i] === '--poll') opts.pollIntervalMs = Number(argv[++i])
14
+ else if (argv[i] === '--saveDir') opts.saveDir = argv[++i]
15
+ }
16
+
17
+ const platform = opts.platform
18
+ const deviceId = opts.deviceId
19
+ const timeoutMs = opts.timeoutMs
20
+ const stabilityMs = opts.stabilityMs
21
+ const poll = opts.pollIntervalMs
22
+ const saveDir = opts.saveDir
23
+
24
+ const ts = () => Date.now()
25
+
26
+ async function findAndTapGenerate() {
27
+ try {
28
+ const found = await ToolsInteract.findElementHandler({ query: 'Generate Session', exact: false, timeoutMs: 3000, platform, deviceId })
29
+ const foundFlag = found && typeof found.found !== 'undefined' ? found.found : false
30
+ console.log('find Generate Session:', foundFlag)
31
+ if (foundFlag) {
32
+ const el = found.element
33
+ if (el && el.tapCoordinates) {
34
+ console.log('Tapping at', JSON.stringify(el.tapCoordinates))
35
+ await ToolsInteract.tapHandler({ platform, x: el.tapCoordinates.x, y: el.tapCoordinates.y, deviceId })
36
+ return true
37
+ } else {
38
+ // tap center of parent bounds if available
39
+ if (el && el.bounds) {
40
+ const b = el.bounds
41
+ const cx = Math.floor((b.left + b.right)/2)
42
+ const cy = Math.floor((b.top + b.bottom)/2)
43
+ console.log('Tapping parent center', cx, cy)
44
+ await ToolsInteract.tapHandler({ platform, x: cx, y: cy, deviceId })
45
+ return true
46
+ }
47
+ }
48
+ }
49
+ } catch (e) { console.error('error finding/tapping generate:', e) }
50
+ return false
51
+ }
52
+
53
+ async function captureUITree(savePrefix) {
54
+ try {
55
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId })
56
+ const p = path.join(saveDir, `${savePrefix}_ui_${Date.now()}.json`)
57
+ fs.writeFileSync(p, JSON.stringify(tree, null, 2))
58
+ console.log('Wrote UI tree', p)
59
+ return tree
60
+ } catch (e) { console.error('failed getUITree:', e); return null }
61
+ }
62
+
63
+ async function tryCaptureScreenshot(savePrefix) {
64
+ try {
65
+ const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
66
+ // handler may return { image: 'data:image/png;base64,...' } or blocks; try to extract base64
67
+ let b64 = null
68
+ if (res && res.image && typeof res.image === 'string') b64 = res.image.replace(/^data:image\/png;base64,/, '')
69
+ else if (Array.isArray(res) && res.length) {
70
+ for (const blk of res) { if (blk && blk.mime === 'image/png' && blk.data) { b64 = blk.data; break } }
71
+ } else if (res && res.blocks) {
72
+ for (const blk of res.blocks) { if (blk && blk.mime === 'image/png' && blk.data) { b64 = blk.data; break } }
73
+ }
74
+ if (b64) {
75
+ const p = path.join(saveDir, `${savePrefix}_shot_${Date.now()}.png`)
76
+ fs.writeFileSync(p, Buffer.from(b64, 'base64'))
77
+ console.log('Wrote screenshot', p)
78
+ return p
79
+ }
80
+ console.log('No image returned from captureScreenshotHandler')
81
+ } catch (e) { console.error('screenshot error:', e) }
82
+ return null
83
+ }
84
+
85
+ async function main() {
86
+ console.log('Starting test: tap Generate Session and wait for Play session to appear stably')
87
+ const tapped = await findAndTapGenerate()
88
+ if (!tapped) console.log('Warning: generate tap not performed')
89
+
90
+ const start = ts()
91
+ const deadline = start + timeoutMs
92
+ let matchedAt = null
93
+ let pollCount = 0
94
+
95
+ while (Date.now() <= deadline) {
96
+ pollCount++
97
+ const savePrefix = `gen_wait_p${pollCount}`
98
+ const tree = await captureUITree(savePrefix)
99
+ try {
100
+ const f = await ToolsInteract.findElementHandler({ query: 'Play session', exact: false, timeoutMs: 500, platform, deviceId })
101
+ const exists = f && typeof f.found !== 'undefined' ? f.found : false
102
+ console.log(`poll ${pollCount}: play exists=${exists}`)
103
+ await tryCaptureScreenshot(savePrefix)
104
+ if (exists) {
105
+ if (matchedAt === null) matchedAt = Date.now()
106
+ const stable = Date.now() - matchedAt
107
+ if (stable >= stabilityMs) {
108
+ console.log('SUCCESS: Play session present and stable for', stable, 'ms after', Date.now()-start, 'ms and', pollCount, 'polls')
109
+ process.exit(0)
110
+ }
111
+ } else {
112
+ matchedAt = null
113
+ }
114
+ } catch (e) { console.error('error during poll:', e) }
115
+
116
+ await new Promise(r => setTimeout(r, poll))
117
+ }
118
+
119
+ console.error('FAIL: timeout waiting for Play session (polls=', pollCount, ')')
120
+ process.exit(2)
121
+ }
122
+
123
+ main().catch(e=>{ console.error('fatal', e); process.exit(1) })
package/dist/server.js CHANGED
@@ -314,6 +314,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
314
314
  required: ["previousFingerprint"]
315
315
  }
316
316
  },
317
+ {
318
+ name: "observe_until",
319
+ 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.",
320
+ inputSchema: {
321
+ type: "object",
322
+ properties: {
323
+ type: { type: "string", enum: ["ui", "log", "screen", "idle"], description: "Condition type to observe", default: "ui" },
324
+ query: { type: "string", description: "Optional query string for ui/log/screen types" },
325
+ timeoutMs: { type: "number", description: "Timeout in ms to wait for condition (default 30000)", default: 30000 },
326
+ pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300, clamped to 250-500)", default: 300 },
327
+ match: { type: "string", enum: ["present", "absent"], description: "Match mode for UI checks: 'present' or 'absent' (default 'present')", default: "present" },
328
+ stability_ms: { type: "number", description: "Stability window in ms that the condition must hold before returning success (default 700)", default: 700 },
329
+ includeSnapshotOnFailure: { type: "boolean", description: "Whether to include a debug snapshot on timeout (default true)", default: true },
330
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override" },
331
+ deviceId: { type: "string", description: "Optional device serial/udid" }
332
+ }
333
+ }
334
+ },
317
335
  {
318
336
  name: "wait_for_element",
319
337
  description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
@@ -19,7 +19,7 @@ function readPropertiesFile(p) {
19
19
  }
20
20
  return out;
21
21
  }
22
- catch (e) {
22
+ catch {
23
23
  return {};
24
24
  }
25
25
  }
@@ -35,7 +35,7 @@ function javaBinExists(p) {
35
35
  return true;
36
36
  return false;
37
37
  }
38
- catch (e) {
38
+ catch {
39
39
  return false;
40
40
  }
41
41
  }
@@ -67,7 +67,7 @@ export async function checkGradle() {
67
67
  }
68
68
  }
69
69
  }
70
- catch (e) { /* ignore */ }
70
+ catch { }
71
71
  // 3) system gradle.properties
72
72
  const systemProps = '/etc/gradle/gradle.properties';
73
73
  filesChecked.push(systemProps);
@@ -82,7 +82,7 @@ export async function checkGradle() {
82
82
  }
83
83
  }
84
84
  }
85
- catch (e) { /* ignore */ }
85
+ catch { }
86
86
  // 4) GRADLE_HOME fallback
87
87
  if (!gradleJavaHome && process.env.GRADLE_HOME) {
88
88
  filesChecked.push(process.env.GRADLE_HOME);
@@ -82,7 +82,7 @@ export async function prepareGradle(projectPath) {
82
82
  try {
83
83
  gradleCheck = await checkGradle();
84
84
  }
85
- catch (e) {
85
+ catch {
86
86
  gradleCheck = { gradleJavaHome: undefined, gradleValid: false, filesChecked: [], issues: [] };
87
87
  }
88
88
  const env = Object.assign({}, process.env);
@@ -127,7 +127,7 @@ export async function prepareGradle(projectPath) {
127
127
  try {
128
128
  delete env.GRADLE_JAVA_HOME;
129
129
  }
130
- catch (e) { }
130
+ catch { }
131
131
  }
132
132
  }
133
133
  if (platformToolsDir) {
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.21.0]
6
+ - Added `observe_until` as a tool for agents to wait for things like API requests
7
+
5
8
  ## [0.20.1]
6
9
  - Fixes gradle home issue for android
7
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }
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.",
@@ -17,7 +17,7 @@ function readPropertiesFile(p: string): Record<string,string> {
17
17
  out[k] = v
18
18
  }
19
19
  return out
20
- } catch (e: unknown) {
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 (e: unknown) { return false }
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 (e: unknown) { /* ignore */ }
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 (e: unknown) { /* ignore */ }
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 (e: unknown) {
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 (e: unknown) { }
120
+ try { delete env.GRADLE_JAVA_HOME } catch { }
121
121
  }
122
122
  }
123
123