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.
- package/dist/interact/index.js +154 -118
- package/dist/scripts/capture_ui_after_tap.mjs +43 -0
- package/dist/scripts/check_play_observe.mjs +18 -0
- package/dist/scripts/check_play_substring.mjs +38 -0
- package/dist/scripts/dump_ui_tree.mjs +20 -0
- package/dist/scripts/observe-test.mjs +32 -0
- package/dist/scripts/press_and_observe.mjs +90 -0
- package/dist/scripts/press_and_wait_ui.mjs +85 -0
- package/dist/scripts/test_generate_and_wait.mjs +123 -0
- package/dist/server.js +18 -0
- package/dist/system/gradle.js +4 -4
- package/dist/utils/android/utils.js +2 -2
- package/docs/CHANGELOG.md +3 -0
- package/package.json +1 -1
- package/src/interact/index.ts +96 -68
- package/src/server.ts +20 -0
- package/src/system/gradle.ts +4 -4
- package/src/utils/android/utils.ts +2 -2
- package/test/observe/unit/observe_until_edge_cases.test.ts +41 -0
- package/test/observe/unit/observe_until_stability.test.ts +30 -0
- package/test/interact/device/observe_until_device.ts +0 -24
|
@@ -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.",
|
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) {
|
package/docs/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/interact/index.ts
CHANGED
|
@@ -29,7 +29,6 @@ interface UiElement {
|
|
|
29
29
|
_interactable?: boolean
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const STABLE_IDLE_MS = 1000
|
|
33
32
|
|
|
34
33
|
export class ToolsInteract {
|
|
35
34
|
|
|
@@ -262,76 +261,105 @@ export class ToolsInteract {
|
|
|
262
261
|
return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
|
|
263
262
|
}
|
|
264
263
|
|
|
265
|
-
static async observeUntilHandler({ type, query, timeoutMs =
|
|
264
|
+
static async observeUntilHandler({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
|
|
266
265
|
const start = Date.now()
|
|
267
266
|
const deadline = start + (timeoutMs || 0)
|
|
268
267
|
const q = (query === null || query === undefined) ? '' : String(query)
|
|
269
268
|
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
try {
|
|
273
|
-
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
274
|
-
initialFingerprint = fpRes?.fingerprint ?? null
|
|
275
|
-
} catch (err) { console.error('observeUntil: error getting initial fingerprint', err); initialFingerprint = null }
|
|
269
|
+
// Clamp polling interval to 250-500ms for consistent behavior
|
|
270
|
+
const pollInterval = Math.max(250, Math.min(pollIntervalMs || 300, 500))
|
|
276
271
|
|
|
277
|
-
//
|
|
272
|
+
// Baseline state (fetch in parallel but bound to short timeouts so observation starts promptly)
|
|
273
|
+
let initialFingerprint: string | null = null
|
|
278
274
|
let baselineLastLine: string | null = null
|
|
279
275
|
try {
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
276
|
+
const fpPromise = ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as Promise<ScreenFingerprintResponse | null>
|
|
277
|
+
const logsPromise = ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as Promise<any>
|
|
278
|
+
const withTimeout = (p: Promise<any>, ms: number) => Promise.race([p, new Promise(resolve => setTimeout(() => resolve(null), ms))])
|
|
279
|
+
const [fpRes, gl] = await Promise.all([withTimeout(fpPromise, 300), withTimeout(logsPromise, 500)])
|
|
280
|
+
if (fpRes && typeof fpRes === 'object') initialFingerprint = (fpRes as ScreenFingerprintResponse).fingerprint ?? null
|
|
281
|
+
if (gl) {
|
|
282
|
+
const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
|
|
283
|
+
baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
|
|
284
|
+
}
|
|
283
285
|
} catch (err) {
|
|
284
|
-
|
|
285
|
-
try { console.warn('observeUntil: failed to get baseline logs (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
|
|
286
|
+
try { console.warn('observeUntil: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
|
|
286
287
|
}
|
|
287
288
|
|
|
288
|
-
|
|
289
|
+
// Network-based waiting removed. Rely on UI and screen fingerprints for determinism.
|
|
289
290
|
let lastChangeAt = Date.now()
|
|
290
291
|
let prevFingerprint = initialFingerprint
|
|
291
292
|
|
|
292
293
|
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
293
294
|
|
|
295
|
+
// Optional initial observation delay requested by caller
|
|
296
|
+
if (typeof observationDelayMs === 'number' && observationDelayMs > 0) {
|
|
297
|
+
try { console.log(`observeUntil: delaying observation for ${observationDelayMs}ms`) } catch { }
|
|
298
|
+
await sleep(observationDelayMs)
|
|
299
|
+
}
|
|
300
|
+
|
|
294
301
|
// Telemetry
|
|
295
302
|
let pollCount = 0
|
|
296
|
-
let
|
|
303
|
+
let matchedAt: number | null = null
|
|
304
|
+
let lastObservedState: boolean | null = null
|
|
305
|
+
let stableDuration = 0
|
|
297
306
|
let matchSource: string | null = null
|
|
298
307
|
|
|
299
308
|
while (Date.now() <= deadline) {
|
|
300
309
|
pollCount++
|
|
301
|
-
|
|
310
|
+
const now = Date.now()
|
|
311
|
+
// Evaluate condition per type
|
|
302
312
|
if (type === 'ui') {
|
|
303
|
-
// fast findElement with short timeout to avoid blocking
|
|
304
313
|
try {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
// Lightweight UI check: fetch UI tree and perform a normalized substring match to reduce overhead
|
|
315
|
+
try {
|
|
316
|
+
// Bound the UI tree fetch to avoid long blocking calls; prefer quick failure over hanging a poll
|
|
317
|
+
const withTimeout = (p: Promise<any>, ms: number) => Promise.race([p, new Promise(resolve => setTimeout(()=>resolve(null), ms))])
|
|
318
|
+
const tree = await withTimeout(ToolsObserve.getUITreeHandler({ platform, deviceId }), Math.min(pollInterval, 500)) as any
|
|
319
|
+
const elems = Array.isArray(tree && tree.elements) ? tree.elements : []
|
|
320
|
+
const qnorm = q.toLowerCase()
|
|
321
|
+
let matched: any = null
|
|
322
|
+
for (const el of elems) {
|
|
323
|
+
try {
|
|
324
|
+
const txt = ((el && (el.text || el.label || el.value || el.contentDescription || el.accessibilityLabel)) || '')
|
|
325
|
+
if (!txt) continue
|
|
326
|
+
if (String(txt).toLowerCase().includes(qnorm)) { matched = el; break }
|
|
327
|
+
} catch { continue }
|
|
328
|
+
}
|
|
329
|
+
const isPresent = !!matched
|
|
330
|
+
const conditionTrue = (match === 'present') ? isPresent : !isPresent
|
|
331
|
+
if (conditionTrue) {
|
|
332
|
+
if (matchedAt === null) matchedAt = Date.now()
|
|
333
|
+
stableDuration = Date.now() - (matchedAt as number)
|
|
334
|
+
lastObservedState = true
|
|
335
|
+
if (stableDuration >= stability_ms) {
|
|
336
|
+
matchSource = 'ui-tree-' + (match === 'present' ? 'present' : 'absent')
|
|
337
|
+
const element = isPresent ? matched : null
|
|
338
|
+
const now2 = Date.now()
|
|
339
|
+
return { success: true, condition: match, query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: stableDuration, matchedElement: element, matchSource, timestamp: now2, type: 'ui', observed_state: lastObservedState ?? null }
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
matchedAt = null
|
|
343
|
+
stableDuration = 0
|
|
344
|
+
lastObservedState = false
|
|
345
|
+
}
|
|
346
|
+
} catch (err) { console.error('observeUntil(ui) tree error:', err) }
|
|
316
347
|
} catch (err) { console.error('observeUntil(ui) find error:', err) }
|
|
317
348
|
} else if (type === 'log') {
|
|
318
349
|
try {
|
|
319
|
-
//
|
|
350
|
+
// Logs: presence semantics only (match 'present'). Stability not applicable (immediate)
|
|
320
351
|
const stream = await ToolsObserve.readLogStreamHandler({ platform, sessionId: 'default', limit: 200 }) as any
|
|
321
352
|
const entries = (stream && Array.isArray(stream.entries)) ? stream.entries : []
|
|
322
353
|
for (const ent of entries) {
|
|
323
354
|
const msg = ent && (ent.message || ent.msg || ent) ? (ent.message || ent.msg || ent) : ''
|
|
324
355
|
if (q && String(msg).includes(q)) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: msg, raw: ent }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
|
|
356
|
+
const now2 = Date.now()
|
|
357
|
+
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: msg, raw: ent }, matchSource: 'log-stream', timestamp: now2, type: 'log', observed_state: true }
|
|
328
358
|
}
|
|
329
359
|
}
|
|
330
360
|
|
|
331
|
-
// Fallback to snapshot logs
|
|
332
361
|
const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
|
|
333
362
|
const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
|
|
334
|
-
// Only consider new lines after baselineLastLine when possible
|
|
335
363
|
let startIndex = 0
|
|
336
364
|
if (baselineLastLine) {
|
|
337
365
|
const idx = logsArr.lastIndexOf(baselineLastLine)
|
|
@@ -340,9 +368,8 @@ export class ToolsInteract {
|
|
|
340
368
|
for (let i = startIndex; i < logsArr.length; i++) {
|
|
341
369
|
const line = logsArr[i]
|
|
342
370
|
if (q && String(line).includes(q)) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: line }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
|
|
371
|
+
const now2 = Date.now()
|
|
372
|
+
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true }
|
|
346
373
|
}
|
|
347
374
|
}
|
|
348
375
|
} catch (err) { console.error('observeUntil(log) error:', err) }
|
|
@@ -351,21 +378,20 @@ export class ToolsInteract {
|
|
|
351
378
|
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
352
379
|
const fp = fpRes?.fingerprint ?? null
|
|
353
380
|
if (fp !== null && fp !== undefined && fp !== initialFingerprint) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
// If query provided but not matched yet, continue polling until timeout
|
|
381
|
+
// when screen changed, require stability_ms where fingerprint remains the same
|
|
382
|
+
if (matchedAt === null) matchedAt = now
|
|
383
|
+
const confirmFp = (await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null)?.fingerprint ?? null
|
|
384
|
+
if (confirmFp === fp) {
|
|
385
|
+
stableDuration = Date.now() - (matchedAt as number)
|
|
386
|
+
lastObservedState = true
|
|
387
|
+
if (stableDuration >= stability_ms) {
|
|
388
|
+
const now2 = Date.now()
|
|
389
|
+
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: stableDuration, newFingerprint: fp, matchSource: 'screen-fingerprint', timestamp: now2, type: 'screen', observed_state: lastObservedState ?? null }
|
|
390
|
+
}
|
|
365
391
|
} else {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
392
|
+
matchedAt = null
|
|
393
|
+
stableDuration = 0
|
|
394
|
+
lastObservedState = false
|
|
369
395
|
}
|
|
370
396
|
}
|
|
371
397
|
} catch (err) { console.error('observeUntil(screen) error:', err) }
|
|
@@ -376,33 +402,35 @@ export class ToolsInteract {
|
|
|
376
402
|
if (fp !== prevFingerprint) {
|
|
377
403
|
prevFingerprint = fp
|
|
378
404
|
lastChangeAt = Date.now()
|
|
405
|
+
matchedAt = null
|
|
406
|
+
stableDuration = 0
|
|
407
|
+
lastObservedState = false
|
|
379
408
|
} else {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
409
|
+
const idleMs = Date.now() - lastChangeAt
|
|
410
|
+
lastObservedState = true
|
|
411
|
+
if (idleMs >= stability_ms) {
|
|
412
|
+
const now2 = Date.now()
|
|
413
|
+
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: idleMs, matchSource: 'idle-stable', timestamp: now2, type: 'idle', observed_state: lastObservedState ?? null }
|
|
384
414
|
}
|
|
385
415
|
}
|
|
386
416
|
} catch (err) { console.error('observeUntil(idle) error:', err) }
|
|
387
417
|
}
|
|
388
|
-
} catch (err) {
|
|
389
|
-
console.error('observeUntil: unexpected error', err)
|
|
390
|
-
}
|
|
391
418
|
|
|
392
419
|
// Respect poll interval and avoid tight loop
|
|
393
|
-
await sleep(
|
|
420
|
+
await sleep(pollInterval)
|
|
394
421
|
}
|
|
395
422
|
|
|
396
|
-
// On timeout, capture a failure snapshot to aid debugging (best-effort)
|
|
423
|
+
// On timeout, optionally capture a failure snapshot to aid debugging (best-effort)
|
|
397
424
|
let snapshot: any = null
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
425
|
+
if (includeSnapshotOnFailure) {
|
|
426
|
+
try {
|
|
427
|
+
snapshot = await ToolsObserve.captureDebugSnapshotHandler({ reason: `observe_until timeout for ${type}`, includeLogs: true, platform, deviceId })
|
|
428
|
+
} catch (err) {
|
|
429
|
+
snapshot = { error: err instanceof Error ? err.message : String(err) }
|
|
430
|
+
}
|
|
402
431
|
}
|
|
403
432
|
|
|
404
433
|
const elapsed = Date.now() - start
|
|
405
|
-
return { success: false,
|
|
406
|
-
}
|
|
407
|
-
|
|
434
|
+
return { success: false, condition: match, query: q, poll_count: pollCount, duration_ms: elapsed, stable_duration_ms: stableDuration, error: 'Timeout waiting for condition', snapshot, observed_state: lastObservedState ?? null }
|
|
435
|
+
}
|
|
408
436
|
}
|
package/src/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.",
|
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
|
|