mobile-debug-mcp 0.21.4 → 0.22.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/AGENTS.md +74 -0
- package/README.md +24 -5
- package/dist/interact/index.js +263 -41
- package/dist/observe/ios.js +10 -3
- package/dist/server-core.js +707 -0
- package/dist/server.js +6 -693
- package/dist/utils/resolve-device.js +15 -3
- package/docs/CHANGELOG.md +9 -1
- package/docs/tools/interact.md +69 -30
- package/package.json +3 -3
- package/skills/README.md +35 -0
- package/skills/test-authoring/SKILL.md +57 -0
- package/skills/test-authoring/references/repo-test-layout.md +47 -0
- package/skills/test-authoring/references/test-authoring-workflow.md +73 -0
- package/skills/test-authoring/references/test-quality-checklist.md +39 -0
- package/src/interact/index.ts +286 -38
- package/src/observe/ios.ts +12 -3
- package/src/server-core.ts +762 -0
- package/src/server.ts +8 -754
- package/src/types.ts +10 -1
- package/src/utils/resolve-device.ts +19 -3
- package/test/device/automated/observe/capture_screenshot.android.smoke.ts +30 -0
- package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +30 -0
- package/test/{observe/device → device/automated/observe}/get_logs.android.smoke.ts +1 -1
- package/test/{observe/device → device/automated/observe}/get_logs.ios.smoke.ts +1 -1
- package/test/device/automated/observe/get_ui_tree.android.smoke.ts +31 -0
- package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +31 -0
- package/test/device/index.ts +52 -0
- package/test/{interact/device/smoke-test.ts → device/manual/interact/app_lifecycle.manual.ts} +5 -5
- package/test/{manage/device/run-build-install-ios.ts → device/manual/manage/build_install_ios.manual.ts} +1 -1
- package/test/{manage/device → device/manual/manage}/install.integration.ts +6 -6
- package/test/{manage/device/run-install-android.ts → device/manual/manage/install_android.manual.ts} +1 -1
- package/test/{manage/device/run-install-ios.ts → device/manual/manage/install_ios.manual.ts} +1 -1
- package/test/device/manual/observe/capture_screenshot.manual.ts +29 -0
- package/test/{helpers/run-get-logs.ts → device/manual/observe/get_logs.manual.ts} +1 -1
- package/test/device/manual/observe/get_ui_tree.manual.ts +29 -0
- package/test/{observe/device/logstream-real.ts → device/manual/observe/logstream.manual.ts} +1 -1
- package/test/{observe/device/run-screen-fingerprint.ts → device/manual/observe/screen_fingerprint.manual.ts} +1 -1
- package/test/{observe/device/run-scroll-test-android.ts → device/manual/observe/scroll_to_element_android.manual.ts} +1 -1
- package/test/{observe/device/test-ui-tree.ts → device/manual/observe/ui_tree.manual.ts} +6 -6
- package/test/unit/index.ts +47 -27
- package/test/unit/interact/handler_shapes.test.ts +55 -0
- package/test/unit/interact/tap_element.test.ts +170 -0
- package/test/unit/interact/wait_for_screen_change.test.ts +34 -0
- package/test/{interact/unit → unit/interact}/wait_for_ui_contract.test.ts +11 -10
- package/test/unit/interact/wait_for_ui_selector_matching.test.ts +76 -0
- package/test/unit/manage/handler_shapes.test.ts +43 -0
- package/test/{observe/unit → unit/observe}/capture_debug_snapshot.test.ts +5 -1
- package/test/{observe/unit → unit/observe}/find_element.test.ts +12 -6
- package/test/unit/observe/get_screen_fingerprint.test.ts +71 -0
- package/test/unit/observe/ios-getlogs.test.ts +53 -0
- package/test/unit/observe/scroll_to_element.test.ts +127 -0
- package/test/unit/server/contract.test.ts +45 -0
- package/test/unit/server/response_shapes.test.ts +93 -0
- package/test/unit/system/adb_version.test.ts +35 -0
- package/test/unit/system/get_system_status.test.ts +20 -0
- package/test/unit/system/system_status.test.ts +141 -0
- package/test/{utils → unit/utils}/detect_java.test.ts +1 -1
- package/test/unit/utils/exec.test.ts +51 -0
- package/test/unit/utils/resolve_device.test.ts +63 -0
- package/tsconfig.json +2 -2
- package/test/interact/device/run-real-test.ts +0 -3
- package/test/interact/unit/wait_for_screen_change.test.ts +0 -32
- package/test/interact/unit/wait_for_ui.test.ts +0 -76
- package/test/interact/unit/wait_for_ui_new.test.ts +0 -57
- package/test/observe/device/wait_for_element_real.ts +0 -3
- package/test/observe/unit/get_screen_fingerprint.test.ts +0 -69
- package/test/observe/unit/ios-getlogs.test.ts +0 -67
- package/test/observe/unit/scroll_to_element.test.ts +0 -129
- package/test/observe/unit/wait_for_element_mock.ts +0 -2
- package/test/observe/unit/wait_for_ui_edge_cases.test.ts +0 -41
- package/test/observe/unit/wait_for_ui_stability.test.ts +0 -30
- package/test/system/adb_version.test.ts +0 -25
- package/test/system/get_system_status.test.ts +0 -52
- package/test/system/system_status.test.ts +0 -109
- /package/test/{manage/unit → unit/manage}/build.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/build_and_install.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/detection.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/diagnostics.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/install.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/mcp_disable_autodetect.test.ts +0 -0
- /package/test/{observe/unit → unit/observe}/get_logs.test.ts +0 -0
- /package/test/{observe/unit → unit/observe}/logparse.test.ts +0 -0
- /package/test/{observe/unit → unit/observe}/logstream.test.ts +0 -0
package/src/interact/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { createHash } from 'crypto'
|
|
1
2
|
import { AndroidInteract } from './android.js';
|
|
2
3
|
import { iOSInteract } from './ios.js';
|
|
3
4
|
export { AndroidInteract, iOSInteract };
|
|
4
5
|
|
|
5
6
|
import { resolveTargetDevice } from '../utils/resolve-device.js'
|
|
6
7
|
import { ToolsObserve } from '../observe/index.js'
|
|
8
|
+
import type { TapElementResponse } from '../types.js'
|
|
7
9
|
|
|
8
10
|
interface ScreenFingerprintResponse { fingerprint: string | null }
|
|
9
11
|
|
|
@@ -29,8 +31,159 @@ interface UiElement {
|
|
|
29
31
|
_interactable?: boolean
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
interface ResolvedUiElementContext {
|
|
35
|
+
elementId: string
|
|
36
|
+
platform: 'android' | 'ios'
|
|
37
|
+
deviceId?: string
|
|
38
|
+
bounds: [number, number, number, number] | null
|
|
39
|
+
index: number
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
|
|
33
43
|
export class ToolsInteract {
|
|
44
|
+
private static readonly _maxResolvedUiElements = 256
|
|
45
|
+
private static _resolvedUiElements = new Map<string, ResolvedUiElementContext>()
|
|
46
|
+
|
|
47
|
+
private static _normalize(s: any): string {
|
|
48
|
+
if (s === null || s === undefined) return ''
|
|
49
|
+
try { return String(s).toLowerCase().trim() } catch { return '' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private static _normalizeBounds(bounds: any): [number, number, number, number] | null {
|
|
53
|
+
if (!Array.isArray(bounds) || bounds.length < 4) return null
|
|
54
|
+
const normalized = bounds.slice(0, 4).map((value: any) => Number(value))
|
|
55
|
+
if (normalized.some((value: number) => Number.isNaN(value))) return null
|
|
56
|
+
return normalized as [number, number, number, number]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private static _isVisibleElement(el: UiElement): boolean {
|
|
60
|
+
const bounds = ToolsInteract._normalizeBounds(el.bounds)
|
|
61
|
+
return !!el.visible && !!bounds && bounds[2] > bounds[0] && bounds[3] > bounds[1]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static _computeElementId(platform: 'android' | 'ios', deviceId: string | undefined, el: UiElement, index: number): string {
|
|
65
|
+
const identity = {
|
|
66
|
+
platform,
|
|
67
|
+
deviceId: deviceId || '',
|
|
68
|
+
text: ToolsInteract._normalize(el.text ?? el.label ?? el.value ?? ''),
|
|
69
|
+
resourceId: ToolsInteract._normalize(el.resourceId ?? el.resourceID ?? el.id ?? ''),
|
|
70
|
+
accessibilityId: ToolsInteract._normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? el.label ?? ''),
|
|
71
|
+
class: ToolsInteract._normalize(el.type ?? el.class ?? ''),
|
|
72
|
+
bounds: ToolsInteract._normalizeBounds(el.bounds) ?? [0, 0, 0, 0],
|
|
73
|
+
index
|
|
74
|
+
}
|
|
75
|
+
return `el_${createHash('sha1').update(JSON.stringify(identity)).digest('hex').slice(0, 24)}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private static _buildResolvedElement(platform: 'android' | 'ios', deviceId: string | undefined, el: UiElement, index: number) {
|
|
79
|
+
const bounds = ToolsInteract._normalizeBounds(el.bounds)
|
|
80
|
+
const elementId = ToolsInteract._computeElementId(platform, deviceId, el, index)
|
|
81
|
+
|
|
82
|
+
ToolsInteract._rememberResolvedElement(elementId, {
|
|
83
|
+
elementId,
|
|
84
|
+
platform,
|
|
85
|
+
deviceId,
|
|
86
|
+
bounds,
|
|
87
|
+
index
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
text: el.text ?? null,
|
|
92
|
+
resource_id: el.resourceId ?? el.resourceID ?? el.id ?? null,
|
|
93
|
+
accessibility_id: el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? el.label ?? null,
|
|
94
|
+
class: el.type ?? el.class ?? null,
|
|
95
|
+
bounds,
|
|
96
|
+
index,
|
|
97
|
+
elementId
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private static _rememberResolvedElement(elementId: string, context: ResolvedUiElementContext) {
|
|
102
|
+
if (ToolsInteract._resolvedUiElements.has(elementId)) {
|
|
103
|
+
ToolsInteract._resolvedUiElements.delete(elementId)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
ToolsInteract._resolvedUiElements.set(elementId, context)
|
|
107
|
+
|
|
108
|
+
while (ToolsInteract._resolvedUiElements.size > ToolsInteract._maxResolvedUiElements) {
|
|
109
|
+
const oldestElementId = ToolsInteract._resolvedUiElements.keys().next().value
|
|
110
|
+
if (!oldestElementId) break
|
|
111
|
+
ToolsInteract._resolvedUiElements.delete(oldestElementId)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static _resetResolvedUiElementsForTests() {
|
|
116
|
+
ToolsInteract._resolvedUiElements.clear()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private static _findCurrentResolvedElement(
|
|
120
|
+
elements: UiElement[],
|
|
121
|
+
platform: 'android' | 'ios',
|
|
122
|
+
deviceId: string | undefined,
|
|
123
|
+
resolved: ResolvedUiElementContext
|
|
124
|
+
): { el: UiElement, index: number } | null {
|
|
125
|
+
const indexedCandidate = elements[resolved.index]
|
|
126
|
+
if (indexedCandidate && ToolsInteract._computeElementId(platform, deviceId, indexedCandidate, resolved.index) === resolved.elementId) {
|
|
127
|
+
return { el: indexedCandidate, index: resolved.index }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private static _resolveActionableAncestor(elements: UiElement[], chosen: { el: UiElement, idx: number } | null): { el: UiElement, idx: number } | null {
|
|
134
|
+
if (!chosen) return null
|
|
135
|
+
if (chosen.el.clickable || chosen.el.focusable) return chosen
|
|
136
|
+
|
|
137
|
+
let current = chosen
|
|
138
|
+
let safety = 0
|
|
139
|
+
|
|
140
|
+
while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable) && current.el.parentId !== undefined && current.el.parentId !== null) {
|
|
141
|
+
const parentId = current.el.parentId
|
|
142
|
+
let parentIndex: number | null = null
|
|
143
|
+
|
|
144
|
+
if (typeof parentId === 'number') parentIndex = parentId
|
|
145
|
+
else if (typeof parentId === 'string' && /^\d+$/.test(parentId)) parentIndex = Number(parentId)
|
|
146
|
+
|
|
147
|
+
if (parentIndex !== null && elements[parentIndex]) {
|
|
148
|
+
current = { el: elements[parentIndex], idx: parentIndex }
|
|
149
|
+
if (current.el.clickable || current.el.focusable) return current
|
|
150
|
+
} else if (typeof parentId === 'string') {
|
|
151
|
+
const foundIndex = elements.findIndex((el) => el.resourceId === parentId || el.id === parentId)
|
|
152
|
+
if (foundIndex === -1) break
|
|
153
|
+
current = { el: elements[foundIndex], idx: foundIndex }
|
|
154
|
+
if (current.el.clickable || current.el.focusable) return current
|
|
155
|
+
} else {
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
safety++
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const childBounds = ToolsInteract._normalizeBounds(chosen.el.bounds)
|
|
163
|
+
if (!childBounds) return null
|
|
164
|
+
const [cl, ct, cr, cb] = childBounds
|
|
165
|
+
|
|
166
|
+
let best: { el: UiElement, idx: number } | null = null
|
|
167
|
+
let bestArea = Infinity
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < elements.length; i++) {
|
|
170
|
+
const el = elements[i]
|
|
171
|
+
if (!el || !(el.clickable || el.focusable)) continue
|
|
172
|
+
const bounds = ToolsInteract._normalizeBounds(el.bounds)
|
|
173
|
+
if (!bounds) continue
|
|
174
|
+
const [pl, pt, pr, pb] = bounds
|
|
175
|
+
if (pl <= cl && pt <= ct && pr >= cr && pb >= cb) {
|
|
176
|
+
const area = (pr - pl) * (pb - pt)
|
|
177
|
+
if (area < bestArea) {
|
|
178
|
+
bestArea = area
|
|
179
|
+
best = { el, idx: i }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return best
|
|
185
|
+
}
|
|
186
|
+
|
|
34
187
|
|
|
35
188
|
private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
|
|
36
189
|
const effectivePlatform = platform || 'android'
|
|
@@ -44,6 +197,99 @@ export class ToolsInteract {
|
|
|
44
197
|
return await interact.tap(x, y, resolved.id)
|
|
45
198
|
}
|
|
46
199
|
|
|
200
|
+
static async tapElementHandler({ elementId }: { elementId: string }): Promise<TapElementResponse> {
|
|
201
|
+
const action = 'tap' as const
|
|
202
|
+
const resolved = ToolsInteract._resolvedUiElements.get(elementId)
|
|
203
|
+
if (!resolved) {
|
|
204
|
+
return {
|
|
205
|
+
success: false,
|
|
206
|
+
elementId,
|
|
207
|
+
action,
|
|
208
|
+
error: {
|
|
209
|
+
code: 'element_not_found',
|
|
210
|
+
message: 'Element ID was not found in the current UI context'
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
|
|
216
|
+
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform
|
|
217
|
+
const treeDeviceId = tree?.device?.id || resolved.deviceId
|
|
218
|
+
const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
|
|
219
|
+
const currentMatch = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved)
|
|
220
|
+
|
|
221
|
+
if (!currentMatch) {
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
elementId,
|
|
225
|
+
action,
|
|
226
|
+
error: {
|
|
227
|
+
code: 'element_not_found',
|
|
228
|
+
message: 'Element ID is not present in the current UI context'
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!ToolsInteract._isVisibleElement(currentMatch.el)) {
|
|
234
|
+
return {
|
|
235
|
+
success: false,
|
|
236
|
+
elementId,
|
|
237
|
+
action,
|
|
238
|
+
error: {
|
|
239
|
+
code: 'element_not_visible',
|
|
240
|
+
message: 'Element is not visible'
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (currentMatch.el.enabled === false) {
|
|
246
|
+
return {
|
|
247
|
+
success: false,
|
|
248
|
+
elementId,
|
|
249
|
+
action,
|
|
250
|
+
error: {
|
|
251
|
+
code: 'element_not_enabled',
|
|
252
|
+
message: 'Element is not enabled'
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const bounds = ToolsInteract._normalizeBounds(currentMatch.el.bounds) ?? resolved.bounds
|
|
258
|
+
if (!bounds || bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) {
|
|
259
|
+
return {
|
|
260
|
+
success: false,
|
|
261
|
+
elementId,
|
|
262
|
+
action,
|
|
263
|
+
error: {
|
|
264
|
+
code: 'element_not_visible',
|
|
265
|
+
message: 'Element does not have valid visible bounds'
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const x = Math.floor((bounds[0] + bounds[2]) / 2)
|
|
271
|
+
const y = Math.floor((bounds[1] + bounds[3]) / 2)
|
|
272
|
+
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId })
|
|
273
|
+
|
|
274
|
+
if (!tapResult.success) {
|
|
275
|
+
return {
|
|
276
|
+
success: false,
|
|
277
|
+
elementId,
|
|
278
|
+
action,
|
|
279
|
+
error: {
|
|
280
|
+
code: 'tap_failed',
|
|
281
|
+
message: tapResult.error || 'Tap failed'
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
success: true,
|
|
288
|
+
elementId,
|
|
289
|
+
action
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
47
293
|
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }) {
|
|
48
294
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
49
295
|
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
|
|
@@ -68,7 +314,7 @@ export class ToolsInteract {
|
|
|
68
314
|
// Try to use observe layer to fetch the current UI tree and perform a fast semantic search
|
|
69
315
|
const start = Date.now()
|
|
70
316
|
const deadline = start + timeoutMs
|
|
71
|
-
const normalize =
|
|
317
|
+
const normalize = ToolsInteract._normalize
|
|
72
318
|
|
|
73
319
|
const q = normalize(query)
|
|
74
320
|
if (!q) return { found: false, error: 'Empty query' }
|
|
@@ -221,16 +467,20 @@ export class ToolsInteract {
|
|
|
221
467
|
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
|
|
222
468
|
const overallStart = Date.now()
|
|
223
469
|
|
|
224
|
-
// Validate selector: require at least one
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
470
|
+
// Validate selector: require at least one non-empty field (text, resource_id, or accessibility_id)
|
|
471
|
+
const hasText = typeof selector?.text === 'string' && selector.text.trim().length > 0;
|
|
472
|
+
const hasResId = typeof selector?.resource_id === 'string' && selector.resource_id.trim().length > 0;
|
|
473
|
+
const hasAccId = typeof selector?.accessibility_id === 'string' && selector.accessibility_id.trim().length > 0;
|
|
228
474
|
|
|
229
|
-
const hasText = selector && typeof (selector as any).text === 'string' && (selector as any).text.trim().length > 0
|
|
230
|
-
const hasResId = selector && typeof (selector as any).resource_id === 'string' && (selector as any).resource_id.trim().length > 0
|
|
231
|
-
const hasAccId = selector && typeof (selector as any).accessibility_id === 'string' && (selector as any).accessibility_id.trim().length > 0
|
|
232
475
|
if (!hasText && !hasResId && !hasAccId) {
|
|
233
|
-
return {
|
|
476
|
+
return {
|
|
477
|
+
status: 'timeout',
|
|
478
|
+
error: {
|
|
479
|
+
code: 'INVALID_SELECTOR',
|
|
480
|
+
message: 'Selector must include at least one non-empty field: text, resource_id, or accessibility_id'
|
|
481
|
+
},
|
|
482
|
+
metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }
|
|
483
|
+
};
|
|
234
484
|
}
|
|
235
485
|
|
|
236
486
|
// Validate condition
|
|
@@ -251,11 +501,11 @@ export class ToolsInteract {
|
|
|
251
501
|
let totalPollCount = 0
|
|
252
502
|
|
|
253
503
|
// Precompute normalized selector values and helpers (constant across polls)
|
|
254
|
-
const normalize =
|
|
255
|
-
const containsFlag = !!selector
|
|
256
|
-
const selText = normalize(
|
|
257
|
-
const selRid = normalize(
|
|
258
|
-
const selAid = normalize(
|
|
504
|
+
const normalize = ToolsInteract._normalize
|
|
505
|
+
const containsFlag = !!selector?.contains
|
|
506
|
+
const selText = normalize(selector?.text)
|
|
507
|
+
const selRid = normalize(selector?.resource_id)
|
|
508
|
+
const selAid = normalize(selector?.accessibility_id)
|
|
259
509
|
|
|
260
510
|
try {
|
|
261
511
|
while (attempts < maxAttempts) {
|
|
@@ -310,26 +560,28 @@ export class ToolsInteract {
|
|
|
310
560
|
|
|
311
561
|
// Evaluate condition
|
|
312
562
|
const matchedCount = matches.length
|
|
313
|
-
const
|
|
314
|
-
const pickIndex: number = pickIndexProvided ? Number((match as any).index) : 0
|
|
563
|
+
const pickIndex = (typeof match?.index === 'number') ? match!.index as number : undefined
|
|
315
564
|
let chosen: { el: any, idx: number } | null = null
|
|
316
|
-
if (matches.length
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
else
|
|
565
|
+
if (matches.length > 0) {
|
|
566
|
+
if (pickIndex !== undefined) {
|
|
567
|
+
// If a specific index is requested but out of bounds, treat as not matched for this poll (deterministic)
|
|
568
|
+
if (pickIndex >= 0 && pickIndex < matches.length) chosen = matches[pickIndex]
|
|
569
|
+
else chosen = null
|
|
570
|
+
} else {
|
|
571
|
+
chosen = matches[0]
|
|
572
|
+
}
|
|
322
573
|
} else {
|
|
323
|
-
chosen =
|
|
574
|
+
chosen = null
|
|
324
575
|
}
|
|
325
576
|
|
|
326
577
|
let conditionMet = false
|
|
578
|
+
let matchedElement = chosen
|
|
327
579
|
if (condition === 'exists') {
|
|
328
580
|
// when an index is specified, existence requires that specific index be present
|
|
329
|
-
conditionMet =
|
|
581
|
+
conditionMet = (pickIndex !== undefined) ? (chosen !== null) : (matchedCount >= 1)
|
|
330
582
|
} else if (condition === 'not_exists') {
|
|
331
583
|
// when an index is specified, not_exists is true if that index is absent
|
|
332
|
-
conditionMet =
|
|
584
|
+
conditionMet = (pickIndex !== undefined) ? (chosen === null) : (matchedCount === 0)
|
|
333
585
|
} else if (condition === 'visible') {
|
|
334
586
|
if (chosen) {
|
|
335
587
|
const b = chosen.el.bounds
|
|
@@ -337,11 +589,12 @@ export class ToolsInteract {
|
|
|
337
589
|
conditionMet = visibleFlag
|
|
338
590
|
} else conditionMet = false
|
|
339
591
|
} else if (condition === 'clickable') {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
const
|
|
344
|
-
const
|
|
592
|
+
matchedElement = chosen ? (ToolsInteract._resolveActionableAncestor(elements, chosen as { el: UiElement, idx: number }) || chosen) : null
|
|
593
|
+
if (matchedElement) {
|
|
594
|
+
const b = matchedElement.el.bounds
|
|
595
|
+
const visibleFlag = !!matchedElement.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
|
|
596
|
+
const enabled = !!matchedElement.el.enabled
|
|
597
|
+
const clickable = !!matchedElement.el.clickable || !!matchedElement.el._interactable || !!matchedElement.el.focusable
|
|
345
598
|
conditionMet = visibleFlag && enabled && clickable
|
|
346
599
|
} else conditionMet = false
|
|
347
600
|
}
|
|
@@ -350,14 +603,9 @@ export class ToolsInteract {
|
|
|
350
603
|
const now = Date.now()
|
|
351
604
|
const latency_ms = now - overallStart
|
|
352
605
|
// Build element output per spec
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
|
|
357
|
-
class: chosen.el.type ?? chosen.el.class ?? null,
|
|
358
|
-
bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
|
|
359
|
-
index: chosen.idx
|
|
360
|
-
} : null
|
|
606
|
+
const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
|
|
607
|
+
const resolvedDeviceId = tree?.device?.id || deviceId
|
|
608
|
+
const outEl = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null
|
|
361
609
|
|
|
362
610
|
return {
|
|
363
611
|
status: 'success',
|
package/src/observe/ios.ts
CHANGED
|
@@ -9,6 +9,15 @@ import { computeScreenFingerprint } from '../utils/ui/index.js'
|
|
|
9
9
|
import { parsePngSize } from '../utils/image.js'
|
|
10
10
|
|
|
11
11
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
12
|
+
let iosExecCommand = execCommand
|
|
13
|
+
|
|
14
|
+
export function _setIOSExecCommandForTests(fn: typeof execCommand) {
|
|
15
|
+
iosExecCommand = fn
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function _resetIOSExecCommandForTests() {
|
|
19
|
+
iosExecCommand = execCommand
|
|
20
|
+
}
|
|
12
21
|
|
|
13
22
|
interface IDBElement {
|
|
14
23
|
AXFrame?: { x: number | string, y: number | string, width: number | string, height: number | string, w?: number | string, h?: number | string };
|
|
@@ -157,7 +166,7 @@ export class iOSObserve {
|
|
|
157
166
|
const parts = appId.split('.')
|
|
158
167
|
const simpleName = parts[parts.length - 1]
|
|
159
168
|
try {
|
|
160
|
-
const pgrepRes = await
|
|
169
|
+
const pgrepRes = await iosExecCommand(['simctl','spawn', deviceId, 'pgrep', '-f', simpleName], deviceId)
|
|
161
170
|
const out = pgrepRes && pgrepRes.output ? pgrepRes.output.trim() : ''
|
|
162
171
|
const firstLine = out.split(/\r?\n/).find(Boolean)
|
|
163
172
|
if (firstLine) {
|
|
@@ -169,7 +178,7 @@ export class iOSObserve {
|
|
|
169
178
|
}
|
|
170
179
|
}
|
|
171
180
|
const effectivePid = pid || detectedPid || null
|
|
172
|
-
const result = await
|
|
181
|
+
const result = await iosExecCommand(args, deviceId)
|
|
173
182
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
174
183
|
const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
|
|
175
184
|
|
|
@@ -281,7 +290,7 @@ export class iOSObserve {
|
|
|
281
290
|
const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`
|
|
282
291
|
|
|
283
292
|
try {
|
|
284
|
-
await
|
|
293
|
+
await iosExecCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
|
|
285
294
|
|
|
286
295
|
const buffer = await fs.readFile(tmpFile)
|
|
287
296
|
const base64 = buffer.toString('base64')
|