mobile-debug-mcp 0.21.5 → 0.23.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/classify.js +35 -0
- package/dist/interact/index.js +220 -13
- package/dist/network/index.js +232 -0
- package/dist/observe/ios.js +10 -3
- package/dist/server-core.js +822 -0
- package/dist/server.js +6 -693
- package/dist/utils/resolve-device.js +15 -3
- package/docs/CHANGELOG.md +10 -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/classify.ts +64 -0
- package/src/interact/index.ts +250 -13
- package/src/network/index.ts +268 -0
- package/src/observe/ios.ts +12 -3
- package/src/server-core.ts +879 -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/classify_action_outcome.test.ts +110 -0
- 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/unit/network/get_network_activity.test.ts +181 -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,14 +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>()
|
|
34
46
|
|
|
35
47
|
private static _normalize(s: any): string {
|
|
36
48
|
if (s === null || s === undefined) return ''
|
|
37
49
|
try { return String(s).toLowerCase().trim() } catch { return '' }
|
|
38
50
|
}
|
|
39
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
|
+
|
|
40
187
|
|
|
41
188
|
private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
|
|
42
189
|
const effectivePlatform = platform || 'android'
|
|
@@ -50,6 +197,99 @@ export class ToolsInteract {
|
|
|
50
197
|
return await interact.tap(x, y, resolved.id)
|
|
51
198
|
}
|
|
52
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
|
+
|
|
53
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 }) {
|
|
54
294
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
55
295
|
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
|
|
@@ -335,6 +575,7 @@ export class ToolsInteract {
|
|
|
335
575
|
}
|
|
336
576
|
|
|
337
577
|
let conditionMet = false
|
|
578
|
+
let matchedElement = chosen
|
|
338
579
|
if (condition === 'exists') {
|
|
339
580
|
// when an index is specified, existence requires that specific index be present
|
|
340
581
|
conditionMet = (pickIndex !== undefined) ? (chosen !== null) : (matchedCount >= 1)
|
|
@@ -348,11 +589,12 @@ export class ToolsInteract {
|
|
|
348
589
|
conditionMet = visibleFlag
|
|
349
590
|
} else conditionMet = false
|
|
350
591
|
} else if (condition === 'clickable') {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
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
|
|
356
598
|
conditionMet = visibleFlag && enabled && clickable
|
|
357
599
|
} else conditionMet = false
|
|
358
600
|
}
|
|
@@ -361,14 +603,9 @@ export class ToolsInteract {
|
|
|
361
603
|
const now = Date.now()
|
|
362
604
|
const latency_ms = now - overallStart
|
|
363
605
|
// Build element output per spec
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
|
|
368
|
-
class: chosen.el.type ?? chosen.el.class ?? null,
|
|
369
|
-
bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
|
|
370
|
-
index: chosen.idx
|
|
371
|
-
} : 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
|
|
372
609
|
|
|
373
610
|
return {
|
|
374
611
|
status: 'success',
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { execAdb, parseLogLine } from '../utils/android/utils.js'
|
|
2
|
+
import { execCommand } from '../utils/ios/utils.js'
|
|
3
|
+
|
|
4
|
+
export type NetworkErrorCode =
|
|
5
|
+
| 'timeout'
|
|
6
|
+
| 'dns_error'
|
|
7
|
+
| 'tls_error'
|
|
8
|
+
| 'connection_refused'
|
|
9
|
+
| 'connection_reset'
|
|
10
|
+
| 'unknown_network_error'
|
|
11
|
+
|
|
12
|
+
export type NetworkActivityStatus = 'success' | 'failure' | 'retryable'
|
|
13
|
+
|
|
14
|
+
export interface NetworkEvent {
|
|
15
|
+
endpoint: string
|
|
16
|
+
method: string
|
|
17
|
+
statusCode: number | null
|
|
18
|
+
networkError: NetworkErrorCode | null
|
|
19
|
+
status: NetworkActivityStatus
|
|
20
|
+
durationMs: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GetNetworkActivityResult {
|
|
24
|
+
requests: NetworkEvent[]
|
|
25
|
+
count: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Module state ─────────────────────────────────────────────────────────────
|
|
29
|
+
// lastActionTimestamp: set when an action tool fires (tap, swipe, etc.)
|
|
30
|
+
// lastConsumedTimestamp: advanced after each get_network_activity call to prevent duplicates
|
|
31
|
+
let lastActionTimestamp = 0
|
|
32
|
+
let lastConsumedTimestamp = 0
|
|
33
|
+
|
|
34
|
+
export function notifyActionStart(): void {
|
|
35
|
+
lastActionTimestamp = Date.now()
|
|
36
|
+
lastConsumedTimestamp = 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Exposed for unit tests only. */
|
|
40
|
+
export function _setTimestampsForTests(actionTs: number, consumedTs: number): void {
|
|
41
|
+
lastActionTimestamp = actionTs
|
|
42
|
+
lastConsumedTimestamp = consumedTs
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Parsing constants ────────────────────────────────────────────────────────
|
|
46
|
+
const URL_RE = /https?:\/\/[^\s"'\]\)><]+/
|
|
47
|
+
const PATH_RE = /\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+/
|
|
48
|
+
const METHOD_RE = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b/
|
|
49
|
+
|
|
50
|
+
const NETWORK_ERROR_PATTERNS: Array<{ re: RegExp; code: NetworkErrorCode }> = [
|
|
51
|
+
{ re: /timed?\s*out|timeout/i, code: 'timeout' },
|
|
52
|
+
{ re: /dns|name[\s_]resolution|host\s*not\s*found|nodename/i, code: 'dns_error' },
|
|
53
|
+
{ re: /\btls\b|\bssl\b|certificate|handshake/i, code: 'tls_error' },
|
|
54
|
+
{ re: /connection\s*refused/i, code: 'connection_refused' },
|
|
55
|
+
{ re: /connection\s*reset|reset\s*by\s*peer/i, code: 'connection_reset' },
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const BACKGROUND_TOKENS = ['/analytics', '/metrics', '/tracking', '/log', '/events', '/telemetry', '/ping', '/beacon']
|
|
59
|
+
const BACKGROUND_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.css', '.js', '.svg', '.ico', '.woff', '.ttf']
|
|
60
|
+
const FILESYSTEM_PREFIXES = ['/data/', '/system/', '/apex/', '/proc/', '/dev/', '/vendor/', '/product/', '/storage/', '/sdcard/', '/mnt/', '/odm/', '/cache/', '/metadata/', '/acct/', '/sys/']
|
|
61
|
+
const FILESYSTEM_EXTENSIONS = ['.apk', '.apex', '.odex', '.vdex', '.dex', '.so', '.jar', '.bin', '.img', '.db', '.sqlite', '.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.m', '.mm', '.kt', '.java', '.swift']
|
|
62
|
+
|
|
63
|
+
// ─── Parsing helpers ─────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function extractUrl(text: string): string | null {
|
|
66
|
+
const m = text.match(URL_RE)
|
|
67
|
+
return m ? m[0] : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isPlausibleEndpointPath(path: string): boolean {
|
|
71
|
+
const lower = path.toLowerCase()
|
|
72
|
+
if (!lower.startsWith('/')) return false
|
|
73
|
+
if (FILESYSTEM_PREFIXES.some((prefix) => lower.startsWith(prefix))) return false
|
|
74
|
+
if (FILESYSTEM_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractPath(text: string): string | null {
|
|
79
|
+
const m = text.match(PATH_RE)
|
|
80
|
+
if (!m) return null
|
|
81
|
+
return isPlausibleEndpointPath(m[0]) ? m[0] : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toStatusCode(value: string | undefined): number | null {
|
|
85
|
+
if (!value) return null
|
|
86
|
+
const code = Number(value)
|
|
87
|
+
return code >= 100 && code <= 599 ? code : null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function escapeRegExp(value: string): string {
|
|
91
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function extractStatusCode(text: string, url: string | null, path: string | null, method: string | null): number | null {
|
|
95
|
+
const directHttpMatch = text.match(/\bHTTP\/\d(?:\.\d)?\s+([1-5]\d{2})\b/i) || text.match(/\bHTTP\s+([1-5]\d{2})\b/i)
|
|
96
|
+
if (directHttpMatch) return toStatusCode(directHttpMatch[1])
|
|
97
|
+
|
|
98
|
+
const endpointToken = url || path
|
|
99
|
+
const hasEndpointContext = endpointToken !== null
|
|
100
|
+
if (!hasEndpointContext && method === null) return null
|
|
101
|
+
|
|
102
|
+
const labeledMatch = text.match(/\b(?:status(?:\s*code)?|response(?:\s*code)?)\s*[:=]?\s*([1-5]\d{2})\b/i)
|
|
103
|
+
if (labeledMatch && hasEndpointContext) return toStatusCode(labeledMatch[1])
|
|
104
|
+
|
|
105
|
+
if (endpointToken) {
|
|
106
|
+
const escapedEndpoint = escapeRegExp(endpointToken)
|
|
107
|
+
const endpointThenCode = new RegExp(`${escapedEndpoint}[^\\n]*?\\b([1-5]\\d{2})\\b`, 'i')
|
|
108
|
+
const codeThenEndpoint = new RegExp(`\\b([1-5]\\d{2})\\b[^\\n]*?${escapedEndpoint}`, 'i')
|
|
109
|
+
const contextualMatch = text.match(endpointThenCode) || text.match(codeThenEndpoint)
|
|
110
|
+
if (contextualMatch) return toStatusCode(contextualMatch[1])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (method !== null && path !== null) {
|
|
114
|
+
const methodPathCodeMatch = text.match(/\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b[^\n]*?\b([1-5]\d{2})\b/i)
|
|
115
|
+
if (methodPathCodeMatch) return toStatusCode(methodPathCodeMatch[1])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extractMethod(text: string): string | null {
|
|
122
|
+
const m = text.match(METHOD_RE)
|
|
123
|
+
return m ? m[1] : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function detectNetworkError(text: string): NetworkErrorCode | null {
|
|
127
|
+
for (const { re, code } of NETWORK_ERROR_PATTERNS) {
|
|
128
|
+
if (re.test(text)) return code
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function normalizeEndpoint(raw: string): string {
|
|
134
|
+
try {
|
|
135
|
+
const u = new URL(raw.startsWith('/') ? `https://x${raw}` : raw)
|
|
136
|
+
const p = u.pathname.toLowerCase().replace(/\/+$/, '')
|
|
137
|
+
return p || '/'
|
|
138
|
+
} catch {
|
|
139
|
+
return raw.toLowerCase().replace(/\?.*$/, '').replace(/\/+$/, '') || '/'
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function classifyStatus(statusCode: number | null, networkError: NetworkErrorCode | null): NetworkActivityStatus {
|
|
144
|
+
if (networkError !== null) return 'retryable'
|
|
145
|
+
if (statusCode === null) return 'success' // request detected, no failure signal
|
|
146
|
+
if (statusCode >= 200 && statusCode <= 299) return 'success'
|
|
147
|
+
if (statusCode >= 400 && statusCode <= 499) return 'failure'
|
|
148
|
+
return 'retryable' // 5xx, 1xx, 3xx
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function meetsEmissionCriteria(url: string | null, path: string | null, statusCode: number | null, method: string | null): boolean {
|
|
152
|
+
if (url !== null) return true // condition 1: full http/https URL
|
|
153
|
+
if (statusCode !== null) return true // condition 2: valid HTTP status code
|
|
154
|
+
if (method !== null && path !== null) return true // condition 3: method + path
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function classifyEventType(endpoint: string): 'primary' | 'background' {
|
|
159
|
+
const lower = endpoint.toLowerCase()
|
|
160
|
+
if (BACKGROUND_TOKENS.some(t => lower.includes(t))) return 'background'
|
|
161
|
+
if (BACKGROUND_EXTENSIONS.some(e => lower.endsWith(e))) return 'background'
|
|
162
|
+
return 'primary'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function filterToSignificantEvents(events: NetworkEvent[]): NetworkEvent[] {
|
|
166
|
+
if (events.length === 0) return events
|
|
167
|
+
const hasPrimary = events.some(e => classifyEventType(e.endpoint) === 'primary')
|
|
168
|
+
return hasPrimary ? events.filter(e => classifyEventType(e.endpoint) === 'primary') : events
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Exported for unit testing. */
|
|
172
|
+
export function parseMessageToEvent(message: string): NetworkEvent | null {
|
|
173
|
+
const url = extractUrl(message)
|
|
174
|
+
const path = url ? null : extractPath(message)
|
|
175
|
+
const method = extractMethod(message)
|
|
176
|
+
const statusCode = extractStatusCode(message, url, path, method)
|
|
177
|
+
const networkError = detectNetworkError(message)
|
|
178
|
+
|
|
179
|
+
if (!meetsEmissionCriteria(url, path, statusCode, method)) return null
|
|
180
|
+
|
|
181
|
+
const rawEndpoint = url || path || 'unknown'
|
|
182
|
+
return {
|
|
183
|
+
endpoint: normalizeEndpoint(rawEndpoint),
|
|
184
|
+
method: method || 'unknown',
|
|
185
|
+
statusCode,
|
|
186
|
+
networkError,
|
|
187
|
+
status: classifyStatus(statusCode, networkError),
|
|
188
|
+
durationMs: 0
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Android ─────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async function getAndroidEvents(sinceMs: number, deviceId?: string): Promise<NetworkEvent[]> {
|
|
195
|
+
try {
|
|
196
|
+
const stdout = await execAdb(['logcat', '-d', '-v', 'threadtime', '*:V', '-t', '2000'], deviceId)
|
|
197
|
+
const lines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : []
|
|
198
|
+
|
|
199
|
+
const events: NetworkEvent[] = []
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const parsed = parseLogLine(line)
|
|
202
|
+
if (parsed._iso) {
|
|
203
|
+
const ts = new Date(parsed._iso).getTime()
|
|
204
|
+
if (ts > 0 && ts <= sinceMs) continue
|
|
205
|
+
}
|
|
206
|
+
const event = parseMessageToEvent(parsed.message || line)
|
|
207
|
+
if (event) events.push(event)
|
|
208
|
+
}
|
|
209
|
+
return events
|
|
210
|
+
} catch {
|
|
211
|
+
return []
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── iOS ─────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async function getIOSEvents(sinceMs: number, deviceId = 'booted'): Promise<NetworkEvent[]> {
|
|
218
|
+
try {
|
|
219
|
+
const lookbackSeconds = Math.max(15, Math.ceil((Date.now() - sinceMs) / 1000) + 5)
|
|
220
|
+
const args = [
|
|
221
|
+
'simctl', 'spawn', deviceId, 'log', 'show',
|
|
222
|
+
'--last', `${lookbackSeconds}s`,
|
|
223
|
+
'--style', 'syslog',
|
|
224
|
+
'--predicate', 'eventMessage contains "http" OR eventMessage contains "URLSession" OR eventMessage contains "Task <" OR eventMessage contains "HTTP/"'
|
|
225
|
+
]
|
|
226
|
+
const result = await execCommand(args, deviceId)
|
|
227
|
+
const lines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
|
|
228
|
+
|
|
229
|
+
const events: NetworkEvent[] = []
|
|
230
|
+
for (const line of lines) {
|
|
231
|
+
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/)
|
|
232
|
+
if (tsMatch) {
|
|
233
|
+
const ts = new Date(tsMatch[1]).getTime()
|
|
234
|
+
if (ts > 0 && ts <= sinceMs) continue
|
|
235
|
+
}
|
|
236
|
+
const event = parseMessageToEvent(line)
|
|
237
|
+
if (event) events.push(event)
|
|
238
|
+
}
|
|
239
|
+
return events
|
|
240
|
+
} catch {
|
|
241
|
+
return []
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
export class ToolsNetwork {
|
|
248
|
+
static notifyActionStart(): void {
|
|
249
|
+
notifyActionStart()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
static async getNetworkActivity(params: { platform: string; deviceId?: string }): Promise<GetNetworkActivityResult> {
|
|
253
|
+
const { platform, deviceId } = params
|
|
254
|
+
|
|
255
|
+
const sinceMs = lastConsumedTimestamp > lastActionTimestamp
|
|
256
|
+
? lastConsumedTimestamp
|
|
257
|
+
: lastActionTimestamp > 0 ? lastActionTimestamp : Date.now() - 30000
|
|
258
|
+
|
|
259
|
+
const raw = platform === 'android'
|
|
260
|
+
? await getAndroidEvents(sinceMs, deviceId)
|
|
261
|
+
: await getIOSEvents(sinceMs, deviceId)
|
|
262
|
+
|
|
263
|
+
const requests = filterToSignificantEvents(raw)
|
|
264
|
+
lastConsumedTimestamp = Date.now()
|
|
265
|
+
|
|
266
|
+
return { requests, count: requests.length }
|
|
267
|
+
}
|
|
268
|
+
}
|
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')
|