mobile-debug-mcp 0.15.0 → 0.17.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,33 @@
1
+ import { spawn } from 'child_process'
2
+
3
+ export type ExecOptions = { timeout?: number; env?: NodeJS.ProcessEnv; cwd?: string; shell?: boolean }
4
+
5
+ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {}): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
6
+ const { timeout = 0, env, cwd, shell } = opts
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell })
9
+ let stdout = ''
10
+ let stderr = ''
11
+ if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString() })
12
+ if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString() })
13
+
14
+ let timedOut = false
15
+ const timer = timeout && timeout > 0 ? setTimeout(() => {
16
+ timedOut = true
17
+ try { child.kill() } catch { }
18
+ resolve({ exitCode: null, stdout: stdout.trim(), stderr: stderr.trim() })
19
+ }, timeout) : null
20
+
21
+ child.on('close', (code) => {
22
+ if (timer) clearTimeout(timer)
23
+ if (timedOut) return
24
+ resolve({ exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() })
25
+ })
26
+
27
+ child.on('error', (err) => {
28
+ if (timer) clearTimeout(timer)
29
+ reject(err)
30
+ })
31
+ })
32
+ }
33
+
@@ -1,4 +1,76 @@
1
- import { UIElement, GetUITreeResponse, SwipeResponse } from '../../types.js'
1
+ import crypto from 'crypto'
2
+ import { GetUITreeResponse, GetCurrentScreenResponse, UIElement, SwipeResponse } from '../../types.js'
3
+
4
+ const ANDROID_STRUCTURAL_TYPES = ['Window','Application','View','ViewGroup','LinearLayout','FrameLayout','RelativeLayout','ScrollView','RecyclerView','TextView','ImageView']
5
+ const IOS_STRUCTURAL_TYPES = ['Window','Application','View','ViewController','UITableView','UICollectionView','UILabel','UIImageView','UIView','UIWindow','UIStackView','UITextView','UITableViewCell']
6
+
7
+ function isDynamicText(t?: string): boolean {
8
+ if (!t) return false
9
+ const txt = t.trim()
10
+ if (!txt) return false
11
+ if (/\b\d{1,2}:\d{2}\b/.test(txt)) return true
12
+ if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt)) return true
13
+ if (/^\d+(?:\.\d+)?%$/.test(txt)) return true
14
+ if (/^\d+$/.test(txt)) return true
15
+ if (/^[\d,]{1,10}$/.test(txt)) return true
16
+ return false
17
+ }
18
+
19
+ function normalizeElement(e: UIElement) {
20
+ return {
21
+ type: (e.type || '').toString(),
22
+ resourceId: (e.resourceId || '').toString(),
23
+ text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
24
+ contentDesc: (e.contentDescription || '').toString(),
25
+ bounds: Array.isArray(e.bounds) ? e.bounds.slice(0,4).map((n:any)=>Number(n)||0) : [0,0,0,0]
26
+ }
27
+ }
28
+
29
+ export function computeScreenFingerprint(tree: GetUITreeResponse, current: GetCurrentScreenResponse | null, platform: 'android' | 'ios', limit: number = 50): { fingerprint: string | null; activity?: string; error?: string } {
30
+ try {
31
+ if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
32
+
33
+ const activity = current && (current.activity || (current as any).shortActivity) ? (current.activity || (current as any).shortActivity) : ''
34
+
35
+ const candidates: UIElement[] = (tree.elements || []).filter(e => {
36
+ if (!e) return false
37
+ if (!e.visible) return false
38
+ const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0
39
+ const hasResource = !!e.resourceId
40
+ const interactable = !!e.clickable || !!e.enabled
41
+ const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES
42
+ const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '')
43
+ return interactable || structurallySignificant
44
+ }) as UIElement[]
45
+
46
+ const normalized = candidates.map(normalizeElement)
47
+
48
+ const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0))
49
+
50
+ filteredNormalized.sort((a,b) => {
51
+ const ay = (a.bounds && a.bounds[1]) || 0
52
+ const by = (b.bounds && b.bounds[1]) || 0
53
+ if (ay !== by) return ay - by
54
+ const ax = (a.bounds && a.bounds[0]) || 0
55
+ const bx = (b.bounds && b.bounds[0]) || 0
56
+ return ax - bx
57
+ })
58
+
59
+ const limited = filteredNormalized.slice(0, Math.max(0, limit))
60
+
61
+ const payload = {
62
+ activity: platform === 'android' ? (activity || '') : '',
63
+ resolution: (tree as any).resolution || { width: 0, height: 0 },
64
+ elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
65
+ }
66
+
67
+ const combined = JSON.stringify(payload)
68
+ const hash = crypto.createHash('sha256').update(combined).digest('hex')
69
+ return { fingerprint: hash, activity: activity }
70
+ } catch (e) {
71
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
72
+ }
73
+ }
2
74
 
3
75
  export interface ScrollSelector { text?: string; resourceId?: string; contentDesc?: string; className?: string }
4
76
 
@@ -84,7 +156,6 @@ export async function scrollToElementShared(opts: {
84
156
  try {
85
157
  await swipe(x1, y1, x2, y2, duration, deviceId)
86
158
  } catch (e) {
87
- // Log swipe failures to aid debugging but don't fail the overall flow
88
159
  try { console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`) } catch {}
89
160
  }
90
161
 
@@ -0,0 +1,32 @@
1
+ import { ToolsInteract } from '../../../src/interact/index.js'
2
+ import * as Observe from '../../../src/observe/index.js'
3
+
4
+ const original = (Observe as any).ToolsObserve.getScreenFingerprintHandler
5
+
6
+ async function runTests() {
7
+ console.log('Starting tests for wait_for_screen_change...')
8
+
9
+ // Test 1: Immediate change
10
+ let seq1: Array<string | null> = ['B','B']
11
+ ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq1.length ? seq1.shift() : null })
12
+ const start1 = Date.now()
13
+ const res1 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 2000, pollIntervalMs: 50 })
14
+ const elapsed1 = Date.now() - start1
15
+ console.log('Test 1: Immediate change ->', (res1 && (res1 as any).success === true && (res1 as any).newFingerprint === 'B') ? 'PASS' : 'FAIL', 'Elapsed:', elapsed1, 'ms')
16
+
17
+ // Test 2: Transient nulls then stable change
18
+ let seq2: Array<string | null> = [null, null, 'B', 'B']
19
+ ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq2.length ? seq2.shift() : 'B' })
20
+ const res2 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 3000, pollIntervalMs: 50 })
21
+ console.log('Test 2: Transient nulls ->', (res2 && (res2 as any).success === true && (res2 as any).newFingerprint === 'B') ? 'PASS' : 'FAIL')
22
+
23
+ // Test 3: Timeout
24
+ ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: 'A' })
25
+ const res3 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 300, pollIntervalMs: 50 })
26
+ console.log('Test 3: Timeout ->', (res3 && (res3 as any).success === false && (res3 as any).reason === 'timeout') ? 'PASS' : 'FAIL')
27
+
28
+ // Restore original
29
+ ;(Observe as any).ToolsObserve.getScreenFingerprintHandler = original
30
+ }
31
+
32
+ runTests().catch(console.error)
@@ -0,0 +1,89 @@
1
+ import { ToolsObserve } from '../../../src/observe/index.js'
2
+
3
+ async function run() {
4
+ console.log('Starting capture_debug_snapshot unit tests...')
5
+
6
+ // Save original ToolsObserve handlers
7
+ const origCaptureHandler = (ToolsObserve as any).captureScreenshotHandler
8
+ const origGetCurrentHandler = (ToolsObserve as any).getCurrentScreenHandler
9
+ const origGetFpHandler = (ToolsObserve as any).getScreenFingerprintHandler
10
+ const origGetTreeHandler = (ToolsObserve as any).getUITreeHandler
11
+ const origReadLogStreamHandler = (ToolsObserve as any).readLogStreamHandler
12
+ const origGetLogsHandler = (ToolsObserve as any).getLogsHandler
13
+
14
+ try {
15
+ // --- Test 1: all components succeed and logs come from stream ---
16
+ ;(ToolsObserve as any).captureScreenshotHandler = async function() {
17
+ return { device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }, screenshot: 'BASE64PNG', resolution: { width: 1080, height: 1920 } }
18
+ }
19
+ ;(ToolsObserve as any).getCurrentScreenHandler = async function() {
20
+ return { device: { platform: 'android', id: 'mock' }, package: 'com.example', activity: 'com.example.Main', shortActivity: 'Main' }
21
+ }
22
+ ;(ToolsObserve as any).getScreenFingerprintHandler = async function() {
23
+ return { fingerprint: 'abc123', activity: 'Main' }
24
+ }
25
+ ;(ToolsObserve as any).getUITreeHandler = async function() {
26
+ return { device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] }
27
+ }
28
+ ;(ToolsObserve as any).readLogStreamHandler = async function() {
29
+ return { entries: [ { timestamp: '2026-03-23T20:00:00.000Z', level: 'ERROR', message: 'Boom' } ], crash_summary: { crash_detected: true } }
30
+ }
31
+ ;(ToolsObserve as any).getLogsHandler = async function() {
32
+ return { device: { platform: 'android', id: 'mock' }, logs: [], logCount: 0 }
33
+ }
34
+
35
+ const res1: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: true, logLines: 50, sessionId: 's1' })
36
+ console.log('res1:', JSON.stringify(res1, null, 2))
37
+ const pass1 = res1 && res1.screenshot === 'BASE64PNG' && res1.activity && res1.fingerprint === 'abc123' && Array.isArray(res1.logs) && res1.logs.length === 1
38
+ console.log('Test 1:', pass1 ? 'PASS' : 'FAIL')
39
+
40
+ // Restore handlers before next test
41
+ ;(ToolsObserve as any).captureScreenshotHandler = origCaptureHandler
42
+ ;(ToolsObserve as any).getCurrentScreenHandler = origGetCurrentHandler
43
+ ;(ToolsObserve as any).getScreenFingerprintHandler = origGetFpHandler
44
+ ;(ToolsObserve as any).getUITreeHandler = origGetTreeHandler
45
+ ;(ToolsObserve as any).readLogStreamHandler = origReadLogStreamHandler
46
+ ;(ToolsObserve as any).getLogsHandler = origGetLogsHandler
47
+
48
+ // --- Test 2: screenshot and ui tree fail; logs fallback to getLogs ---
49
+ ;(ToolsObserve as any).captureScreenshotHandler = async function() { throw new Error('screencap failed') }
50
+ ;(ToolsObserve as any).getUITreeHandler = async function() { throw new Error('uie_error') }
51
+ ;(ToolsObserve as any).readLogStreamHandler = async function() { return { entries: [] } }
52
+ ;(ToolsObserve as any).getLogsHandler = async function() { return { device: { platform: 'android', id: 'mock' }, logs: ['INFO starting','ERROR crash here'], logCount: 2 } }
53
+
54
+ const res2: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: true, logLines: 10, appId: 'com.example' })
55
+ console.log('res2:', JSON.stringify(res2, null, 2))
56
+ const pass2 = res2 && res2.screenshot_error && res2.ui_tree_error && Array.isArray(res2.logs) && res2.logs.length === 2
57
+ console.log('Test 2:', pass2 ? 'PASS' : 'FAIL')
58
+
59
+ // Restore handlers before next test
60
+ ;(ToolsObserve as any).captureScreenshotHandler = origCaptureHandler
61
+ ;(ToolsObserve as any).getCurrentScreenHandler = origGetCurrentHandler
62
+ ;(ToolsObserve as any).getScreenFingerprintHandler = origGetFpHandler
63
+ ;(ToolsObserve as any).getUITreeHandler = origGetTreeHandler
64
+ ;(ToolsObserve as any).readLogStreamHandler = origReadLogStreamHandler
65
+ ;(ToolsObserve as any).getLogsHandler = origGetLogsHandler
66
+
67
+ // --- Test 3: includeLogs=false should omit logs ---
68
+ ;(ToolsObserve as any).captureScreenshotHandler = async function() { return { device: { platform: 'android', id: 'mock' }, screenshot: null } }
69
+ ;(ToolsObserve as any).getCurrentScreenHandler = async function() { return { device: { platform: 'android', id: 'mock' }, package: '', activity: '', shortActivity: '' } }
70
+ ;(ToolsObserve as any).getScreenFingerprintHandler = async function() { return { fingerprint: null } }
71
+ ;(ToolsObserve as any).getUITreeHandler = async function() { return { device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 0, height: 0 }, elements: [] } }
72
+ ;(ToolsObserve as any).readLogStreamHandler = async function() { return { entries: [] } }
73
+
74
+ const res3: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: false })
75
+ console.log('res3:', JSON.stringify(res3, null, 2))
76
+ const pass3 = res3 && typeof res3.logs !== 'undefined' && res3.logs.length === 0
77
+ console.log('Test 3:', pass3 ? 'PASS' : 'FAIL')
78
+
79
+ } finally {
80
+ ;(ToolsObserve as any).captureScreenshotHandler = origCaptureHandler
81
+ ;(ToolsObserve as any).getCurrentScreenHandler = origGetCurrentHandler
82
+ ;(ToolsObserve as any).getScreenFingerprintHandler = origGetFpHandler
83
+ ;(ToolsObserve as any).getUITreeHandler = origGetTreeHandler
84
+ ;(ToolsObserve as any).readLogStreamHandler = origReadLogStreamHandler
85
+ ;(ToolsObserve as any).getLogsHandler = origGetLogsHandler
86
+ }
87
+ }
88
+
89
+ run().catch(console.error)
@@ -10,5 +10,7 @@ import '../manage/unit/build_and_install.test.ts'
10
10
  import '../manage/unit/diagnostics.test.ts'
11
11
  import '../manage/unit/detection.test.ts'
12
12
  import '../manage/unit/mcp_disable_autodetect.test.ts'
13
+ import '../interact/unit/wait_for_screen_change.test.ts'
14
+ import '../observe/unit/capture_debug_snapshot.test.ts'
13
15
 
14
16
  console.log('Unit tests loaded.')
@@ -1,73 +0,0 @@
1
- import crypto from 'crypto'
2
- import { GetUITreeResponse, GetCurrentScreenResponse, UIElement } from '../../types.js'
3
-
4
- const ANDROID_STRUCTURAL_TYPES = ['Window','Application','View','ViewGroup','LinearLayout','FrameLayout','RelativeLayout','ScrollView','RecyclerView','TextView','ImageView']
5
- const IOS_STRUCTURAL_TYPES = ['Window','Application','View','ViewController','UITableView','UICollectionView','UILabel','UIImageView','UIView','UIWindow','UIStackView','UITextView','UITableViewCell']
6
-
7
- function isDynamicText(t?: string): boolean {
8
- if (!t) return false
9
- const txt = t.trim()
10
- if (!txt) return false
11
- if (/\b\d{1,2}:\d{2}\b/.test(txt)) return true
12
- if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt)) return true
13
- if (/^\d+(?:\.\d+)?%$/.test(txt)) return true
14
- if (/^\d+$/.test(txt)) return true
15
- if (/^[\d,]{1,10}$/.test(txt)) return true
16
- return false
17
- }
18
-
19
- function normalizeElement(e: UIElement) {
20
- return {
21
- type: (e.type || '').toString(),
22
- resourceId: (e.resourceId || '').toString(),
23
- text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
24
- contentDesc: (e.contentDescription || '').toString(),
25
- bounds: Array.isArray(e.bounds) ? e.bounds.slice(0,4).map((n:any)=>Number(n)||0) : [0,0,0,0]
26
- }
27
- }
28
-
29
- export function computeScreenFingerprint(tree: GetUITreeResponse, current: GetCurrentScreenResponse | null, platform: 'android' | 'ios', limit: number = 50): { fingerprint: string | null; activity?: string; error?: string } {
30
- try {
31
- if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
32
-
33
- const activity = current && (current.activity || (current as any).shortActivity) ? (current.activity || (current as any).shortActivity) : ''
34
-
35
- const candidates: UIElement[] = (tree.elements || []).filter(e => {
36
- if (!e) return false
37
- if (!e.visible) return false
38
- const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0
39
- const hasResource = !!e.resourceId
40
- const interactable = !!e.clickable || !!e.enabled
41
- const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES
42
- const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '')
43
- return interactable || structurallySignificant
44
- }) as UIElement[]
45
-
46
- const normalized = candidates.map(normalizeElement)
47
-
48
- const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0))
49
-
50
- filteredNormalized.sort((a,b) => {
51
- const ay = (a.bounds && a.bounds[1]) || 0
52
- const by = (b.bounds && b.bounds[1]) || 0
53
- if (ay !== by) return ay - by
54
- const ax = (a.bounds && a.bounds[0]) || 0
55
- const bx = (b.bounds && b.bounds[0]) || 0
56
- return ax - bx
57
- })
58
-
59
- const limited = filteredNormalized.slice(0, Math.max(0, limit))
60
-
61
- const payload = {
62
- activity: platform === 'android' ? (activity || '') : '',
63
- resolution: (tree as any).resolution || { width: 0, height: 0 },
64
- elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
65
- }
66
-
67
- const combined = JSON.stringify(payload)
68
- const hash = crypto.createHash('sha256').update(combined).digest('hex')
69
- return { fingerprint: hash, activity: activity }
70
- } catch (e) {
71
- return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
72
- }
73
- }