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.
- package/dist/interact/android.js +1 -1
- package/dist/interact/index.js +39 -0
- package/dist/interact/ios.js +1 -1
- package/dist/interact/shared/fingerprint.js +1 -72
- package/dist/interact/shared/scroll_to_element.js +1 -98
- package/dist/observe/android.js +1 -1
- package/dist/observe/index.js +103 -0
- package/dist/observe/ios.js +1 -1
- package/dist/server.js +41 -0
- package/dist/utils/android/utils.js +11 -67
- package/dist/utils/exec.js +34 -0
- package/dist/utils/ui/index.js +169 -0
- package/docs/CHANGELOG.md +7 -0
- package/docs/tools/interact.md +29 -0
- package/docs/tools/observe.md +44 -0
- package/package.json +1 -1
- package/src/interact/android.ts +1 -1
- package/src/interact/index.ts +45 -0
- package/src/interact/ios.ts +1 -1
- package/src/observe/android.ts +1 -1
- package/src/observe/index.ts +88 -0
- package/src/observe/ios.ts +1 -1
- package/src/server.ts +45 -0
- package/src/types.ts +1 -0
- package/src/utils/android/utils.ts +10 -77
- package/src/utils/exec.ts +33 -0
- package/src/{interact/shared/scroll_to_element.ts → utils/ui/index.ts} +73 -2
- package/test/interact/unit/wait_for_screen_change.test.ts +32 -0
- package/test/observe/unit/capture_debug_snapshot.test.ts +89 -0
- package/test/unit/index.ts +2 -0
- package/src/interact/shared/fingerprint.ts +0 -73
|
@@ -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
|
|
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)
|
package/test/unit/index.ts
CHANGED
|
@@ -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
|
-
}
|