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
|
@@ -0,0 +1,90 @@
|
|
|
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-test'
|
|
7
|
+
console.log('Starting log stream for', bundle)
|
|
8
|
+
try {
|
|
9
|
+
const start = await ToolsObserve.startLogStreamHandler({ platform: 'ios', packageName: bundle, sessionId })
|
|
10
|
+
console.log('startLogStream result:', JSON.stringify(start))
|
|
11
|
+
} catch (e) {
|
|
12
|
+
console.error('startLogStream failed:', e instanceof Error ? e.message : String(e))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Try to find the Generate Session element
|
|
16
|
+
console.log('\nSearching for "Generate Session" UI element...')
|
|
17
|
+
let el = null
|
|
18
|
+
try {
|
|
19
|
+
const found = await ToolsInteract.findElementHandler({ query: 'Generate Session', exact: false, timeoutMs: 3000, platform: 'ios' })
|
|
20
|
+
const foundFlag = found && typeof (found).found !== 'undefined' ? found.found : false
|
|
21
|
+
console.log('findElementHandler:', foundFlag ? 'found' : 'not found')
|
|
22
|
+
if (foundFlag) {
|
|
23
|
+
el = found.element
|
|
24
|
+
console.log('Matched element:', JSON.stringify(el))
|
|
25
|
+
}
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error('findElementHandler error:', e instanceof Error ? e.message : String(e))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// If found, tap it. Otherwise, try tapping a best-effort coordinate in center of screen
|
|
31
|
+
try {
|
|
32
|
+
if (el && el.tapCoordinates) {
|
|
33
|
+
console.log('Tapping matched element at', JSON.stringify(el.tapCoordinates))
|
|
34
|
+
await ToolsInteract.tapHandler({ platform: 'ios', x: el.tapCoordinates.x, y: el.tapCoordinates.y })
|
|
35
|
+
} else {
|
|
36
|
+
console.log('Element not found; attempting center tap as fallback')
|
|
37
|
+
// attempt a center tap (may be harmless)
|
|
38
|
+
await ToolsInteract.tapHandler({ platform: 'ios', x: 200, y: 400 })
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.error('Tap failed:', e instanceof Error ? e.message : String(e))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log('\nObserving until network_idle (30s timeout)')
|
|
45
|
+
// Start a parallel log monitor to print network-like lines as they appear
|
|
46
|
+
// Print all log lines originating from the app bundle for inspection
|
|
47
|
+
let seenIds = new Set()
|
|
48
|
+
let monitorActive = true
|
|
49
|
+
const monitor = (async () => {
|
|
50
|
+
while (monitorActive) {
|
|
51
|
+
try {
|
|
52
|
+
const stream = await ToolsObserve.readLogStreamHandler({ platform: 'ios', sessionId, limit: 200 })
|
|
53
|
+
const entries = (stream && Array.isArray(stream.entries)) ? stream.entries : []
|
|
54
|
+
for (const ent of entries) {
|
|
55
|
+
const id = JSON.stringify(ent).slice(0,200)
|
|
56
|
+
if (seenIds.has(id)) continue
|
|
57
|
+
seenIds.add(id)
|
|
58
|
+
const msg = ent && (ent.message || ent.msg || ent) ? (ent.message || ent.msg || ent) : ''
|
|
59
|
+
// Print only lines that reference the app bundle
|
|
60
|
+
if (String(msg).includes('Modul8') || String(msg).includes('modul8') || (ent && ent.process && String(ent.process).toLowerCase().includes('modul8'))) {
|
|
61
|
+
console.log('[APP LOG]', new Date().toISOString(), JSON.stringify({ message: msg }))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch (e) { console.error('log monitor error:', e instanceof Error ? e.message : String(e)) }
|
|
65
|
+
await new Promise(r => setTimeout(r, 300))
|
|
66
|
+
}
|
|
67
|
+
})()
|
|
68
|
+
|
|
69
|
+
let observeResult = null
|
|
70
|
+
try {
|
|
71
|
+
observeResult = await ToolsInteract.observeUntilHandler({ type: 'network_idle', timeoutMs: 30000, pollIntervalMs: 300, platform: 'ios', includeSnapshotOnFailure: true })
|
|
72
|
+
console.log('observeUntil result:')
|
|
73
|
+
console.log(JSON.stringify(observeResult, null, 2))
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.error('observeUntil failed:', e instanceof Error ? e.message : String(e))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Stop monitor
|
|
79
|
+
monitorActive = false
|
|
80
|
+
|
|
81
|
+
console.log('\nStopping log stream (best-effort)')
|
|
82
|
+
try {
|
|
83
|
+
const stop = await ToolsObserve.stopLogStreamHandler({ platform: 'ios', sessionId })
|
|
84
|
+
console.log('stopLogStream result:', JSON.stringify(stop))
|
|
85
|
+
} catch (e) { console.error('stopLogStream failed:', e) }
|
|
86
|
+
|
|
87
|
+
process.exit(0)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
main().catch(err => { console.error(err); process.exit(1) })
|
|
@@ -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
|
@@ -315,31 +315,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
315
315
|
}
|
|
316
316
|
},
|
|
317
317
|
{
|
|
318
|
-
name: "
|
|
319
|
-
description: "Wait
|
|
318
|
+
name: "wait_for_ui",
|
|
319
|
+
description: "Wait for a UI/log/screen/idle condition with a stability window before returning success.",
|
|
320
320
|
inputSchema: {
|
|
321
321
|
type: "object",
|
|
322
322
|
properties: {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
},
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
type: "number",
|
|
334
|
-
description: "Max wait time in ms (default 10000)",
|
|
335
|
-
default: 10000
|
|
336
|
-
},
|
|
337
|
-
deviceId: {
|
|
338
|
-
type: "string",
|
|
339
|
-
description: "Device Serial/UDID. Defaults to connected/booted device."
|
|
340
|
-
}
|
|
341
|
-
},
|
|
342
|
-
required: ["platform", "text"]
|
|
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
|
+
}
|
|
343
333
|
}
|
|
344
334
|
},
|
|
345
335
|
{
|
|
@@ -626,9 +616,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
626
616
|
const res = await ToolsInteract.waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId });
|
|
627
617
|
return wrapResponse(res);
|
|
628
618
|
}
|
|
629
|
-
if (name === "
|
|
630
|
-
const {
|
|
631
|
-
const res = await ToolsInteract.
|
|
619
|
+
if (name === "wait_for_ui") {
|
|
620
|
+
const { type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId } = (args || {});
|
|
621
|
+
const res = await ToolsInteract.waitForUIHandler({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
|
|
632
622
|
return wrapResponse(res);
|
|
633
623
|
}
|
|
634
624
|
if (name === "find_element") {
|
package/dist/system/gradle.js
CHANGED
|
@@ -19,7 +19,7 @@ function readPropertiesFile(p) {
|
|
|
19
19
|
}
|
|
20
20
|
return out;
|
|
21
21
|
}
|
|
22
|
-
catch
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
130
|
+
catch { }
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
if (platformToolsDir) {
|
|
@@ -23,6 +23,11 @@ export async function listDevices(platform, appId) {
|
|
|
23
23
|
export async function resolveTargetDevice(opts) {
|
|
24
24
|
const { platform, appId, prefer, deviceId } = opts;
|
|
25
25
|
const devices = await listDevices(platform, appId);
|
|
26
|
+
// During unit tests (no adb/xcrun available), provide a lightweight mock device so
|
|
27
|
+
// the observe/interact unit tests can run without real devices.
|
|
28
|
+
if ((!devices || devices.length === 0) && (process.env.NODE_ENV === 'test' || process.env.MCP_TEST_MOCK_DEVICES === '1')) {
|
|
29
|
+
return { id: 'mock', platform: platform || 'android', osVersion: '12', model: 'Pixel', simulator: true };
|
|
30
|
+
}
|
|
26
31
|
if (deviceId) {
|
|
27
32
|
const found = devices.find(d => d.id === deviceId);
|
|
28
33
|
if (!found)
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.21.1]
|
|
6
|
+
- Removed wait_for_element and renamed observe_until to wait_for_ui (obsolete references removed)
|
|
7
|
+
|
|
8
|
+
## [0.21.0]
|
|
9
|
+
- Added `wait_for_ui` as a tool for agents to wait for things like API requests
|
|
10
|
+
|
|
5
11
|
## [0.20.1]
|
|
6
12
|
- Fixes gradle home issue for android
|
|
7
13
|
|
|
@@ -21,7 +27,7 @@ All notable changes to the **Mobile Debug MCP** project will be documented in th
|
|
|
21
27
|
|
|
22
28
|
## [0.19.0]
|
|
23
29
|
|
|
24
|
-
- Added `
|
|
30
|
+
- Added `wait_for_ui` interaction tool: waits for UI, log, screen fingerprint or idle conditions with configurable polling and timeout. Returns rich details on match (element info, log line, new fingerprint).
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
## [0.18.0]
|
package/docs/tools/interact.md
CHANGED
|
@@ -2,26 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Tools that perform UI interactions: tap, swipe, type_text, press_back, and waiting for elements.
|
|
4
4
|
|
|
5
|
-
## wait_for_element
|
|
6
|
-
Wait until a UI element with matching text appears on screen or timeout is reached.
|
|
7
|
-
|
|
8
|
-
Input:
|
|
9
|
-
|
|
10
|
-
```
|
|
11
|
-
{ "platform": "android", "text": "Home", "timeout": 5000, "deviceId": "emulator-5554" }
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
Response:
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
{ "device": { "platform": "android", "id": "emulator-5554" }, "found": true, "element": { "text": "Home", "resourceId": "com.example:id/home" } }
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Notes:
|
|
21
|
-
- Polls get_ui_tree until timeout or element found. Returns an `error` field if system failures occur.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
5
|
## tap / swipe / type_text / press_back
|
|
26
6
|
|
|
27
7
|
Tap input example:
|
|
@@ -153,7 +133,7 @@ Notes:
|
|
|
153
133
|
|
|
154
134
|
---
|
|
155
135
|
|
|
156
|
-
##
|
|
136
|
+
## wait_for_ui
|
|
157
137
|
|
|
158
138
|
Purpose:
|
|
159
139
|
- Wait for a condition to occur on the device: UI element appearance, a log line, a screen fingerprint change, or an idle/stable screen state.
|
|
@@ -164,7 +144,7 @@ Supported types and behavior:
|
|
|
164
144
|
- screen: Compares screen fingerprints (visual checks) against an initial baseline and returns when fingerprint changes. If `query` is provided it will attempt a `find_element` on the new screen to validate the expected content.
|
|
165
145
|
- idle: Waits until the screen fingerprint remains stable for a short stability window (default 1000ms).
|
|
166
146
|
|
|
167
|
-
Input (ToolsInteract.
|
|
147
|
+
Input (ToolsInteract.waitForUIHandler):
|
|
168
148
|
```
|
|
169
149
|
{ "type": "ui|log|screen|idle", "query": "optional string", "timeoutMs": 5000, "pollIntervalMs": 200, "platform": "android|ios", "deviceId": "optional device id" }
|
|
170
150
|
```
|
|
@@ -190,16 +170,16 @@ Notes & tips:
|
|
|
190
170
|
- For UI-sensitive flows prefer type='ui' rather than relying solely on visual fingerprint changes, as some UI updates don't alter the fingerprint.
|
|
191
171
|
|
|
192
172
|
Tests:
|
|
193
|
-
- Unit: `test/interact/unit/
|
|
194
|
-
- Device runner: `test/interact/device/
|
|
173
|
+
- Unit: `test/interact/unit/wait_for_ui.test.ts`
|
|
174
|
+
- Device runner: `test/interact/device/wait_for_ui_device.ts` (requires devices/emulators and adb/xcrun in PATH)
|
|
195
175
|
|
|
196
176
|
Example:
|
|
197
177
|
```
|
|
198
178
|
// Wait up to 5s for a button labeled "Generate Session" on Android
|
|
199
|
-
ToolsInteract.
|
|
179
|
+
ToolsInteract.waitForUIHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 5000, platform: 'android' })
|
|
200
180
|
```
|
|
201
181
|
|
|
202
182
|
Troubleshooting:
|
|
203
|
-
- If
|
|
204
|
-
- If
|
|
183
|
+
- If wait_for_ui(log) never matches, ensure log streaming is started for the target package and baseline logs captured correctly.
|
|
184
|
+
- If wait_for_ui(screen) times out despite visible UI change, try type='ui' to validate content-level changes.
|
|
205
185
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-debug-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"healthcheck": "tsx ./src/cli/idb/check-idb.ts",
|
|
14
14
|
"install-idb": "tsx ./src/cli/idb/install-idb.ts",
|
|
15
15
|
"preflight-ios": "tsx ./src/cli/ios/preflight-ios.ts",
|
|
16
|
-
"test:unit": "tsx test/unit/index.ts",
|
|
16
|
+
"test:unit": "SKIP_DEVICE_TESTS=1 tsx test/unit/index.ts",
|
|
17
17
|
"test:integration": "npm run build && tsx test/device/index.ts",
|
|
18
18
|
"test:device": "npm run build && tsx test/device/index.ts",
|
|
19
19
|
"test": "npm run test:unit",
|
package/src/interact/android.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
2
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
|
|
3
3
|
import { AndroidObserve } from "../observe/index.js"
|
|
4
4
|
import { scrollToElementShared } from "../utils/ui/index.js"
|
|
@@ -7,37 +7,6 @@ import { scrollToElementShared } from "../utils/ui/index.js"
|
|
|
7
7
|
export class AndroidInteract {
|
|
8
8
|
private observe = new AndroidObserve();
|
|
9
9
|
|
|
10
|
-
async waitForElement(text: string, timeout: number, deviceId?: string): Promise<WaitForElementResponse> {
|
|
11
|
-
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
12
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
13
|
-
const startTime = Date.now();
|
|
14
|
-
|
|
15
|
-
while (Date.now() - startTime < timeout) {
|
|
16
|
-
try {
|
|
17
|
-
const tree = await this.observe.getUITree(deviceId);
|
|
18
|
-
|
|
19
|
-
if (tree.error) {
|
|
20
|
-
return { device: deviceInfo, found: false, error: tree.error };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const element = tree.elements.find(e => e.text === text);
|
|
24
|
-
if (element) {
|
|
25
|
-
return { device: deviceInfo, found: true, element };
|
|
26
|
-
}
|
|
27
|
-
} catch (e) {
|
|
28
|
-
// Ignore errors during polling and retry
|
|
29
|
-
console.error("Error polling UI tree:", e);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const elapsed = Date.now() - startTime;
|
|
33
|
-
const remaining = timeout - elapsed;
|
|
34
|
-
if (remaining <= 0) break;
|
|
35
|
-
|
|
36
|
-
await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
|
|
37
|
-
}
|
|
38
|
-
return { device: deviceInfo, found: false };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
10
|
async tap(x: number, y: number, deviceId?: string): Promise<TapResponse> {
|
|
42
11
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
43
12
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|