mobile-debug-mcp 0.24.8 → 0.25.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/README.md +1 -1
- package/dist/interact/index.js +213 -3
- package/dist/observe/ios.js +56 -2
- package/dist/server/common.js +2 -1
- package/dist/server/tool-definitions.js +55 -0
- package/dist/server/tool-handlers.js +17 -0
- package/dist/server-core.js +1 -1
- package/dist/utils/android/utils.js +67 -1
- package/docs/CHANGELOG.md +3 -0
- package/docs/ROADMAP.md +388 -0
- package/docs/rfcs/001-state-verification.md +452 -0
- package/docs/specs/mcp-tooling-spec-v1.md +4 -0
- package/docs/tools/interact.md +25 -0
- package/docs/tools/observe.md +2 -1
- package/package.json +1 -1
- package/src/interact/index.ts +240 -3
- package/src/observe/ios.ts +62 -3
- package/src/server/common.ts +2 -1
- package/src/server/tool-definitions.ts +55 -0
- package/src/server/tool-handlers.ts +18 -0
- package/src/server-core.ts +1 -1
- package/src/types.ts +41 -0
- package/src/utils/android/utils.ts +78 -14
- package/test/unit/observe/state_extraction.test.ts +43 -0
- package/test/unit/server/response_shapes.test.ts +40 -2
package/src/interact/index.ts
CHANGED
|
@@ -10,7 +10,9 @@ import type {
|
|
|
10
10
|
ActionFailureCode,
|
|
11
11
|
ActionTargetResolved,
|
|
12
12
|
ExpectElementVisibleResponse,
|
|
13
|
+
ExpectStateResponse,
|
|
13
14
|
ExpectScreenResponse,
|
|
15
|
+
UIElementState,
|
|
14
16
|
TapElementResponse
|
|
15
17
|
} from '../types.js'
|
|
16
18
|
|
|
@@ -37,6 +39,7 @@ interface UiElement {
|
|
|
37
39
|
_index?: number
|
|
38
40
|
_interactable?: boolean
|
|
39
41
|
_sliderLike?: boolean
|
|
42
|
+
state?: UIElementState | null
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
interface ResolvedUiElementContext {
|
|
@@ -77,6 +80,45 @@ export class ToolsInteract {
|
|
|
77
80
|
return normalized as [number, number, number, number]
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
private static _matchesSelector(el: UiElement, selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }): boolean {
|
|
84
|
+
if (!selector) return false
|
|
85
|
+
const normalize = ToolsInteract._normalize
|
|
86
|
+
const containsFlag = !!selector.contains
|
|
87
|
+
const text = normalize(el.text ?? el.label ?? el.value ?? '')
|
|
88
|
+
const resourceId = normalize(el.resourceId ?? el.resourceID ?? el.id ?? '')
|
|
89
|
+
const accessibilityId = normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? el.label ?? '')
|
|
90
|
+
|
|
91
|
+
if (selector.text !== undefined && selector.text !== null) {
|
|
92
|
+
const q = normalize(selector.text)
|
|
93
|
+
if (containsFlag ? !text.includes(q) : text !== q) return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (selector.resource_id !== undefined && selector.resource_id !== null) {
|
|
97
|
+
const q = normalize(selector.resource_id)
|
|
98
|
+
if (containsFlag ? !resourceId.includes(q) : resourceId !== q) return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (selector.accessibility_id !== undefined && selector.accessibility_id !== null) {
|
|
102
|
+
const q = normalize(selector.accessibility_id)
|
|
103
|
+
if (containsFlag ? !accessibilityId.includes(q) : accessibilityId !== q) return false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private static _findFirstMatchingElement(
|
|
110
|
+
elements: UiElement[],
|
|
111
|
+
selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }
|
|
112
|
+
): { el: UiElement, idx: number } | null {
|
|
113
|
+
if (!selector) return null
|
|
114
|
+
for (let i = 0; i < elements.length; i++) {
|
|
115
|
+
const el = elements[i]
|
|
116
|
+
if (!el) continue
|
|
117
|
+
if (ToolsInteract._matchesSelector(el, selector)) return { el, idx: i }
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
80
122
|
private static _isVisibleElement(el: UiElement): boolean {
|
|
81
123
|
const bounds = ToolsInteract._normalizeBounds(el.bounds)
|
|
82
124
|
return !!el.visible && !!bounds && bounds[2] > bounds[0] && bounds[3] > bounds[1]
|
|
@@ -154,7 +196,8 @@ export class ToolsInteract {
|
|
|
154
196
|
accessibility_id: element.contentDescription ?? element.contentDesc ?? element.accessibilityLabel ?? element.label ?? null,
|
|
155
197
|
class: element.type ?? element.class ?? null,
|
|
156
198
|
bounds: ToolsInteract._normalizeBounds(element.bounds),
|
|
157
|
-
index
|
|
199
|
+
index,
|
|
200
|
+
state: element.state ?? null
|
|
158
201
|
}
|
|
159
202
|
}
|
|
160
203
|
|
|
@@ -996,7 +1039,8 @@ export class ToolsInteract {
|
|
|
996
1039
|
accessibility_id: result.element.accessibility_id ?? null,
|
|
997
1040
|
class: result.element.class ?? null,
|
|
998
1041
|
bounds: result.element.bounds ?? null,
|
|
999
|
-
index: typeof result.element.index === 'number' ? result.element.index : null
|
|
1042
|
+
index: typeof result.element.index === 'number' ? result.element.index : null,
|
|
1043
|
+
state: (result.element as any).state ?? null
|
|
1000
1044
|
},
|
|
1001
1045
|
observed: {
|
|
1002
1046
|
status: result.status,
|
|
@@ -1010,7 +1054,8 @@ export class ToolsInteract {
|
|
|
1010
1054
|
accessibility_id: result.element.accessibility_id ?? null,
|
|
1011
1055
|
class: result.element.class ?? null,
|
|
1012
1056
|
bounds: result.element.bounds ?? null,
|
|
1013
|
-
index: typeof result.element.index === 'number' ? result.element.index : null
|
|
1057
|
+
index: typeof result.element.index === 'number' ? result.element.index : null,
|
|
1058
|
+
state: (result.element as any).state ?? null
|
|
1014
1059
|
}
|
|
1015
1060
|
},
|
|
1016
1061
|
reason: 'selector is visible'
|
|
@@ -1036,6 +1081,198 @@ export class ToolsInteract {
|
|
|
1036
1081
|
}
|
|
1037
1082
|
}
|
|
1038
1083
|
|
|
1084
|
+
static async expectStateHandler({
|
|
1085
|
+
selector,
|
|
1086
|
+
element_id,
|
|
1087
|
+
property,
|
|
1088
|
+
expected,
|
|
1089
|
+
platform,
|
|
1090
|
+
deviceId
|
|
1091
|
+
}: {
|
|
1092
|
+
selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean },
|
|
1093
|
+
element_id?: string,
|
|
1094
|
+
property: string,
|
|
1095
|
+
expected: boolean | number | string | Record<string, unknown>,
|
|
1096
|
+
platform?: 'android' | 'ios',
|
|
1097
|
+
deviceId?: string
|
|
1098
|
+
}): Promise<ExpectStateResponse> {
|
|
1099
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
|
|
1100
|
+
const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
|
|
1101
|
+
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
|
|
1102
|
+
const treeDeviceId = tree?.device?.id || deviceId
|
|
1103
|
+
|
|
1104
|
+
let matched: { el: UiElement, idx: number } | null = null
|
|
1105
|
+
|
|
1106
|
+
if (element_id) {
|
|
1107
|
+
const resolved = ToolsInteract._resolvedUiElements.get(element_id)
|
|
1108
|
+
if (resolved) {
|
|
1109
|
+
const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved)
|
|
1110
|
+
if (current) matched = { el: current.el, idx: current.index }
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (!matched && selector) {
|
|
1115
|
+
matched = ToolsInteract._findFirstMatchingElement(elements, selector)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (!matched) {
|
|
1119
|
+
return {
|
|
1120
|
+
success: false,
|
|
1121
|
+
selector,
|
|
1122
|
+
element_id: element_id ?? null,
|
|
1123
|
+
expected_state: { property, expected },
|
|
1124
|
+
reason: 'element not found',
|
|
1125
|
+
failure_code: 'ELEMENT_NOT_FOUND',
|
|
1126
|
+
retryable: true
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const resolvedElement = ToolsInteract._resolvedTargetFromElement(
|
|
1131
|
+
ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx),
|
|
1132
|
+
matched.el,
|
|
1133
|
+
matched.idx
|
|
1134
|
+
)
|
|
1135
|
+
const observedState = matched.el.state ?? null
|
|
1136
|
+
const actual = observedState?.[property as keyof UIElementState] ?? null
|
|
1137
|
+
|
|
1138
|
+
const compareBoolean = (value: unknown) => typeof value === 'boolean' ? value : null
|
|
1139
|
+
const compareString = (value: unknown) => typeof value === 'string' ? value : null
|
|
1140
|
+
const compareNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
1141
|
+
|
|
1142
|
+
let success = false
|
|
1143
|
+
let reason = ''
|
|
1144
|
+
let rawValue: boolean | number | string | null = null
|
|
1145
|
+
let observedValue: boolean | number | string | Record<string, unknown> | null = actual as any
|
|
1146
|
+
|
|
1147
|
+
switch (property) {
|
|
1148
|
+
case 'checked':
|
|
1149
|
+
case 'focused':
|
|
1150
|
+
case 'expanded':
|
|
1151
|
+
case 'enabled': {
|
|
1152
|
+
const expectedBool = compareBoolean(expected)
|
|
1153
|
+
const actualBool = compareBoolean(actual)
|
|
1154
|
+
if (expectedBool === null) {
|
|
1155
|
+
reason = `expected ${property} must be boolean`
|
|
1156
|
+
} else if (actualBool === null) {
|
|
1157
|
+
reason = `${property} state unavailable`
|
|
1158
|
+
} else {
|
|
1159
|
+
rawValue = actualBool
|
|
1160
|
+
success = actualBool === expectedBool
|
|
1161
|
+
reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`
|
|
1162
|
+
}
|
|
1163
|
+
observedValue = actualBool
|
|
1164
|
+
break
|
|
1165
|
+
}
|
|
1166
|
+
case 'value':
|
|
1167
|
+
case 'raw_value': {
|
|
1168
|
+
const expectedNumber = compareNumber(expected)
|
|
1169
|
+
const actualNumber = compareNumber(actual)
|
|
1170
|
+
if (expectedNumber !== null && actualNumber !== null) {
|
|
1171
|
+
success = actualNumber === expectedNumber
|
|
1172
|
+
rawValue = actualNumber
|
|
1173
|
+
observedValue = actualNumber
|
|
1174
|
+
reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`
|
|
1175
|
+
break
|
|
1176
|
+
}
|
|
1177
|
+
const expectedString = typeof expected === 'string' ? expected : null
|
|
1178
|
+
const actualString = compareString(actual)
|
|
1179
|
+
if (expectedString !== null && actualString !== null) {
|
|
1180
|
+
success = actualString === expectedString
|
|
1181
|
+
rawValue = actualString
|
|
1182
|
+
observedValue = actualString
|
|
1183
|
+
reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`
|
|
1184
|
+
} else {
|
|
1185
|
+
reason = 'value state unavailable'
|
|
1186
|
+
}
|
|
1187
|
+
break
|
|
1188
|
+
}
|
|
1189
|
+
case 'selected': {
|
|
1190
|
+
const expectedBool = typeof expected === 'boolean' ? expected : null
|
|
1191
|
+
const expectedString = typeof expected === 'string'
|
|
1192
|
+
? expected
|
|
1193
|
+
: expected && typeof expected === 'object'
|
|
1194
|
+
? String((expected as { id?: unknown; label?: unknown }).id ?? (expected as { id?: unknown; label?: unknown }).label ?? '')
|
|
1195
|
+
: null
|
|
1196
|
+
if (!observedState || observedState.selected === undefined || observedState.selected === null) {
|
|
1197
|
+
reason = 'selected state unavailable'
|
|
1198
|
+
break
|
|
1199
|
+
}
|
|
1200
|
+
if (expectedBool !== null) {
|
|
1201
|
+
const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null
|
|
1202
|
+
if (actualBool === null) {
|
|
1203
|
+
reason = 'selected state is not boolean'
|
|
1204
|
+
break
|
|
1205
|
+
}
|
|
1206
|
+
rawValue = actualBool
|
|
1207
|
+
observedValue = actualBool
|
|
1208
|
+
success = actualBool === expectedBool
|
|
1209
|
+
reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`
|
|
1210
|
+
break
|
|
1211
|
+
}
|
|
1212
|
+
const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
|
|
1213
|
+
? String((observedState.selected as { id?: unknown; label?: unknown }).id ?? (observedState.selected as { id?: unknown; label?: unknown }).label ?? '')
|
|
1214
|
+
: String(observedState.selected)
|
|
1215
|
+
const actualString = actualSelected.trim()
|
|
1216
|
+
if (!expectedString) {
|
|
1217
|
+
reason = 'expected selected must be boolean, string, or object with id/label'
|
|
1218
|
+
break
|
|
1219
|
+
}
|
|
1220
|
+
rawValue = actualString
|
|
1221
|
+
observedValue = actualString
|
|
1222
|
+
success = actualString === expectedString
|
|
1223
|
+
reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`
|
|
1224
|
+
break
|
|
1225
|
+
}
|
|
1226
|
+
case 'text_value': {
|
|
1227
|
+
const expectedString = typeof expected === 'string' ? expected : null
|
|
1228
|
+
const actualString = compareString(actual)
|
|
1229
|
+
if (!expectedString) {
|
|
1230
|
+
reason = 'expected text_value must be string'
|
|
1231
|
+
} else if (!actualString) {
|
|
1232
|
+
reason = 'text_value state unavailable'
|
|
1233
|
+
} else {
|
|
1234
|
+
success = actualString === expectedString
|
|
1235
|
+
rawValue = actualString
|
|
1236
|
+
observedValue = actualString
|
|
1237
|
+
reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`
|
|
1238
|
+
}
|
|
1239
|
+
break
|
|
1240
|
+
}
|
|
1241
|
+
default: {
|
|
1242
|
+
if (actual !== null && actual !== undefined) {
|
|
1243
|
+
success = actual === expected
|
|
1244
|
+
observedValue = actual as any
|
|
1245
|
+
rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null
|
|
1246
|
+
reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`
|
|
1247
|
+
} else {
|
|
1248
|
+
reason = `unsupported or unavailable state property: ${property}`
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if (!success && !reason) {
|
|
1254
|
+
reason = `${property} did not match expected value`
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
return {
|
|
1258
|
+
success,
|
|
1259
|
+
selector,
|
|
1260
|
+
element_id: element_id ?? resolvedElement.elementId,
|
|
1261
|
+
expected_state: { property, expected },
|
|
1262
|
+
element: {
|
|
1263
|
+
...resolvedElement,
|
|
1264
|
+
state: observedState
|
|
1265
|
+
},
|
|
1266
|
+
observed_state: {
|
|
1267
|
+
property,
|
|
1268
|
+
value: observedValue,
|
|
1269
|
+
...(rawValue !== null ? { raw_value: rawValue } : {})
|
|
1270
|
+
},
|
|
1271
|
+
reason,
|
|
1272
|
+
...(success ? {} : { failure_code: 'UNKNOWN', retryable: false })
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1039
1276
|
static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
|
|
1040
1277
|
const start = Date.now()
|
|
1041
1278
|
const deadline = start + (timeoutMs || 0)
|
package/src/observe/ios.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { promises as fs } from "fs"
|
|
3
|
-
import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo } from "../types.js"
|
|
3
|
+
import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementState } from "../types.js"
|
|
4
4
|
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
5
5
|
import { createWriteStream, promises as fsPromises } from 'fs'
|
|
6
6
|
import path from 'path'
|
|
@@ -56,7 +56,64 @@ function getCenter(bounds: [number, number, number, number]): [number, number] {
|
|
|
56
56
|
return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function
|
|
59
|
+
function parseIOSNumber(value: unknown): number | null {
|
|
60
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
61
|
+
if (typeof value !== 'string') return null
|
|
62
|
+
const parsed = Number(value)
|
|
63
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isIOSAdjustable(node: IDBElement, type: string, traits: string[]): boolean {
|
|
67
|
+
return /slider|adjustable|stepper|progress/i.test(type) || traits.some((trait) => /adjustable|slider|progress/i.test(trait))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function extractIOSState(node: IDBElement, type: string, label: string | null, value: string | null, traits: string[]): UIElementState | null {
|
|
71
|
+
const state: UIElementState = {}
|
|
72
|
+
const normalizedTraits = traits.map((trait) => String(trait).toLowerCase())
|
|
73
|
+
|
|
74
|
+
if (normalizedTraits.some((trait) => /selected/.test(trait))) {
|
|
75
|
+
state.selected = label || value || true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (normalizedTraits.some((trait) => /focused/.test(trait))) {
|
|
79
|
+
state.focused = true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (normalizedTraits.some((trait) => /enabled/.test(trait))) {
|
|
83
|
+
state.enabled = true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (normalizedTraits.some((trait) => /disabled/.test(trait))) {
|
|
87
|
+
state.enabled = false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (value && /textfield|search|text/i.test(type)) {
|
|
91
|
+
state.text_value = value
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (isIOSAdjustable(node, type, traits)) {
|
|
95
|
+
const rawValue = parseIOSNumber(value)
|
|
96
|
+
if (rawValue !== null) {
|
|
97
|
+
state.raw_value = rawValue
|
|
98
|
+
state.value = rawValue >= 0 && rawValue <= 1 ? Math.round(rawValue * 100) : rawValue
|
|
99
|
+
} else if (value) {
|
|
100
|
+
state.raw_value = value
|
|
101
|
+
state.value = value
|
|
102
|
+
}
|
|
103
|
+
} else if (value) {
|
|
104
|
+
const numericValue = parseIOSNumber(value)
|
|
105
|
+
if (numericValue !== null) {
|
|
106
|
+
state.value = numericValue
|
|
107
|
+
state.raw_value = numericValue
|
|
108
|
+
} else {
|
|
109
|
+
state.value = value
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return Object.keys(state).length > 0 ? state : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: number = -1, depth: number = 0): number {
|
|
60
117
|
if (!node) return -1;
|
|
61
118
|
|
|
62
119
|
let currentIndex = -1;
|
|
@@ -66,6 +123,7 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
|
|
|
66
123
|
const value = node.AXValue || null;
|
|
67
124
|
const frame = node.AXFrame || node.frame;
|
|
68
125
|
const traits = node.AXTraits || [];
|
|
126
|
+
const state = extractIOSState(node, type, label, value, traits);
|
|
69
127
|
|
|
70
128
|
const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell";
|
|
71
129
|
|
|
@@ -83,7 +141,8 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
|
|
|
83
141
|
visible: true,
|
|
84
142
|
bounds: bounds,
|
|
85
143
|
center: getCenter(bounds),
|
|
86
|
-
depth: depth
|
|
144
|
+
depth: depth,
|
|
145
|
+
state
|
|
87
146
|
};
|
|
88
147
|
|
|
89
148
|
if (parentIndex !== -1) {
|
package/src/server/common.ts
CHANGED
|
@@ -96,7 +96,8 @@ export function normalizeResolvedTarget(value: Partial<ActionTargetResolved> | n
|
|
|
96
96
|
accessibility_id: value.accessibility_id ?? null,
|
|
97
97
|
class: value.class ?? null,
|
|
98
98
|
bounds: value.bounds ?? null,
|
|
99
|
-
index: value.index ?? null
|
|
99
|
+
index: value.index ?? null,
|
|
100
|
+
state: value.state ?? null
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -468,6 +468,61 @@ Failure Handling:
|
|
|
468
468
|
required: ['selector']
|
|
469
469
|
}
|
|
470
470
|
},
|
|
471
|
+
{
|
|
472
|
+
name: 'expect_state',
|
|
473
|
+
description: `Purpose:
|
|
474
|
+
Verify a readable UI state property on the currently visible element.
|
|
475
|
+
|
|
476
|
+
Inputs:
|
|
477
|
+
- selector or element_id
|
|
478
|
+
- property
|
|
479
|
+
- expected
|
|
480
|
+
- platform/deviceId (optional)
|
|
481
|
+
|
|
482
|
+
Supported properties:
|
|
483
|
+
- checked, selected, focused, expanded, enabled, text_value, value, raw_value
|
|
484
|
+
|
|
485
|
+
Verification Guidance:
|
|
486
|
+
- Use this when the UI element is visible but its state must also be confirmed
|
|
487
|
+
- Prefer the canonical property names above
|
|
488
|
+
- The tool compares the normalized readable state and returns the observed value when available
|
|
489
|
+
|
|
490
|
+
Constraints:
|
|
491
|
+
- Returns structured success/failure only
|
|
492
|
+
- Does not infer a state when the property is unavailable
|
|
493
|
+
|
|
494
|
+
Failure Handling:
|
|
495
|
+
- ELEMENT_NOT_FOUND → re-resolve the element or wait for UI stabilization
|
|
496
|
+
- UNKNOWN → capture a snapshot and stop`,
|
|
497
|
+
inputSchema: {
|
|
498
|
+
type: 'object',
|
|
499
|
+
properties: {
|
|
500
|
+
selector: {
|
|
501
|
+
type: 'object',
|
|
502
|
+
properties: {
|
|
503
|
+
text: { type: 'string' },
|
|
504
|
+
resource_id: { type: 'string' },
|
|
505
|
+
accessibility_id: { type: 'string' },
|
|
506
|
+
contains: { type: 'boolean', default: false }
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
element_id: { type: 'string', description: 'Optional previously resolved element identifier.' },
|
|
510
|
+
property: { type: 'string', description: 'Readable state property to verify.' },
|
|
511
|
+
expected: {
|
|
512
|
+
description: 'Expected normalized state value.',
|
|
513
|
+
oneOf: [
|
|
514
|
+
{ type: 'boolean' },
|
|
515
|
+
{ type: 'number' },
|
|
516
|
+
{ type: 'string' },
|
|
517
|
+
{ type: 'object' }
|
|
518
|
+
]
|
|
519
|
+
},
|
|
520
|
+
platform: { type: 'string', enum: ['android', 'ios'], description: 'Optional platform override' },
|
|
521
|
+
deviceId: { type: 'string', description: 'Optional device serial/udid' }
|
|
522
|
+
},
|
|
523
|
+
required: ['property', 'expected']
|
|
524
|
+
}
|
|
525
|
+
},
|
|
471
526
|
{
|
|
472
527
|
name: 'wait_for_ui',
|
|
473
528
|
description: `Purpose:
|
|
@@ -258,6 +258,23 @@ async function handleExpectElementVisible(args: ToolCallArgs) {
|
|
|
258
258
|
return wrapResponse(res)
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
async function handleExpectState(args: ToolCallArgs) {
|
|
262
|
+
const selector = getObjectArg<ExpectElementSelectorArg>(args, 'selector')
|
|
263
|
+
const element_id = getStringArg(args, 'element_id')
|
|
264
|
+
const property = requireStringArg(args, 'property')
|
|
265
|
+
const platform = getStringArg(args, 'platform') as PlatformArg | undefined
|
|
266
|
+
const deviceId = getStringArg(args, 'deviceId')
|
|
267
|
+
if (!selector && !element_id) {
|
|
268
|
+
throw new Error('Missing selector or element_id argument')
|
|
269
|
+
}
|
|
270
|
+
if (!Object.prototype.hasOwnProperty.call(args, 'expected')) {
|
|
271
|
+
throw new Error('Missing expected argument')
|
|
272
|
+
}
|
|
273
|
+
const expected = args.expected as boolean | number | string | Record<string, unknown>
|
|
274
|
+
const res = await ToolsInteract.expectStateHandler({ selector: selector ?? undefined, element_id: element_id ?? undefined, property, expected, platform, deviceId })
|
|
275
|
+
return wrapResponse(res)
|
|
276
|
+
}
|
|
277
|
+
|
|
261
278
|
async function handleWaitForUI(args: ToolCallArgs) {
|
|
262
279
|
const selector = getObjectArg<ExpectElementSelectorArg>(args, 'selector')
|
|
263
280
|
const condition = (getStringArg(args, 'condition') as 'exists' | 'not_exists' | 'visible' | 'clickable' | undefined) ?? 'exists'
|
|
@@ -458,6 +475,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
|
|
|
458
475
|
wait_for_screen_change: handleWaitForScreenChange,
|
|
459
476
|
expect_screen: handleExpectScreen,
|
|
460
477
|
expect_element_visible: handleExpectElementVisible,
|
|
478
|
+
expect_state: handleExpectState,
|
|
461
479
|
wait_for_ui: handleWaitForUI,
|
|
462
480
|
find_element: handleFindElement,
|
|
463
481
|
tap: handleTap,
|
package/src/server-core.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -79,6 +79,21 @@ export interface GetCrashResponse {
|
|
|
79
79
|
crashes: string[];
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export interface UIElementState {
|
|
83
|
+
checked?: boolean | null;
|
|
84
|
+
selected?: boolean | string | { id: string; label?: string } | null;
|
|
85
|
+
focused?: boolean | null;
|
|
86
|
+
expanded?: boolean | null;
|
|
87
|
+
enabled?: boolean | null;
|
|
88
|
+
text_value?: string | null;
|
|
89
|
+
value?: number | string | null;
|
|
90
|
+
raw_value?: number | string | null;
|
|
91
|
+
value_range?: {
|
|
92
|
+
min: number;
|
|
93
|
+
max: number;
|
|
94
|
+
} | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
82
97
|
export interface CaptureAndroidScreenResponse {
|
|
83
98
|
device: DeviceInfo;
|
|
84
99
|
screenshot: string; // base64 encoded string
|
|
@@ -116,6 +131,7 @@ export interface UIElement {
|
|
|
116
131
|
children?: number[];
|
|
117
132
|
center?: [number, number];
|
|
118
133
|
depth?: number;
|
|
134
|
+
state?: UIElementState | null;
|
|
119
135
|
}
|
|
120
136
|
|
|
121
137
|
export interface GetUITreeResponse {
|
|
@@ -198,6 +214,7 @@ export interface ActionTargetResolved {
|
|
|
198
214
|
class: string | null;
|
|
199
215
|
bounds: [number, number, number, number] | null;
|
|
200
216
|
index: number | null;
|
|
217
|
+
state?: UIElementState | null;
|
|
201
218
|
}
|
|
202
219
|
|
|
203
220
|
export interface ActionExecutionResult {
|
|
@@ -260,6 +277,30 @@ export interface ExpectElementVisibleResponse {
|
|
|
260
277
|
retryable?: boolean;
|
|
261
278
|
}
|
|
262
279
|
|
|
280
|
+
export interface ExpectStateResponse {
|
|
281
|
+
success: boolean;
|
|
282
|
+
selector?: {
|
|
283
|
+
text?: string;
|
|
284
|
+
resource_id?: string;
|
|
285
|
+
accessibility_id?: string;
|
|
286
|
+
contains?: boolean;
|
|
287
|
+
};
|
|
288
|
+
element_id: string | null;
|
|
289
|
+
expected_state: {
|
|
290
|
+
property: string;
|
|
291
|
+
expected: boolean | number | string | Record<string, unknown>;
|
|
292
|
+
};
|
|
293
|
+
element?: (ActionTargetResolved & { state?: UIElementState | null }) | null;
|
|
294
|
+
observed_state?: {
|
|
295
|
+
property: string;
|
|
296
|
+
value: boolean | number | string | Record<string, unknown> | null;
|
|
297
|
+
raw_value?: boolean | number | string | null;
|
|
298
|
+
};
|
|
299
|
+
reason?: string;
|
|
300
|
+
failure_code?: 'ELEMENT_NOT_FOUND' | 'UNKNOWN';
|
|
301
|
+
retryable?: boolean;
|
|
302
|
+
}
|
|
303
|
+
|
|
263
304
|
export interface SwipeResponse {
|
|
264
305
|
device: DeviceInfo;
|
|
265
306
|
success: boolean;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DeviceInfo, UIElement } from "../../types.js"
|
|
1
|
+
import { DeviceInfo, UIElement, UIElementState } from "../../types.js"
|
|
2
2
|
import { promises as fsPromises, existsSync } from 'fs'
|
|
3
3
|
import path from 'path'
|
|
4
4
|
import { detectJavaHome } from '../java.js'
|
|
@@ -323,6 +323,68 @@ export function getCenter(bounds: [number, number, number, number]): [number, nu
|
|
|
323
323
|
return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
function parseBooleanAttr(value: unknown): boolean | null {
|
|
327
|
+
if (value === true || value === 'true') return true
|
|
328
|
+
if (value === false || value === 'false') return false
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function parseNumberAttr(value: unknown): number | null {
|
|
333
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
334
|
+
if (typeof value !== 'string') return null
|
|
335
|
+
const parsed = Number(value)
|
|
336
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isSliderLikeAndroid(node: any): boolean {
|
|
340
|
+
const className = String(node['@_class'] || '').toLowerCase()
|
|
341
|
+
return /seekbar|slider|range|progress/i.test(className)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function extractAndroidState(node: any): UIElementState | null {
|
|
345
|
+
const checked = parseBooleanAttr(node['@_checked'])
|
|
346
|
+
const selectedFlag = parseBooleanAttr(node['@_selected'])
|
|
347
|
+
const focused = parseBooleanAttr(node['@_focused'])
|
|
348
|
+
const expanded = parseBooleanAttr(node['@_expanded'])
|
|
349
|
+
const enabled = parseBooleanAttr(node['@_enabled'])
|
|
350
|
+
const textValue = typeof node['@_text'] === 'string' && node['@_text'].trim().length > 0 ? node['@_text'] : null
|
|
351
|
+
const state: UIElementState = {}
|
|
352
|
+
|
|
353
|
+
if (checked !== null) state.checked = checked
|
|
354
|
+
if (selectedFlag !== null) {
|
|
355
|
+
state.selected = textValue || node['@_content-desc'] || true
|
|
356
|
+
}
|
|
357
|
+
if (focused !== null) state.focused = focused
|
|
358
|
+
if (expanded !== null) state.expanded = expanded
|
|
359
|
+
if (enabled !== null) state.enabled = enabled
|
|
360
|
+
|
|
361
|
+
if (textValue && /edittext|textfield|search/i.test(String(node['@_class'] || ''))) {
|
|
362
|
+
state.text_value = textValue
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (isSliderLikeAndroid(node)) {
|
|
366
|
+
const rawProgress = parseNumberAttr(node['@_progress'])
|
|
367
|
+
const max = parseNumberAttr(node['@_max'])
|
|
368
|
+
const fallbackValue = rawProgress ?? parseNumberAttr(node['@_value']) ?? parseNumberAttr(node['@_content-desc'])
|
|
369
|
+
const numericValue = rawProgress ?? fallbackValue
|
|
370
|
+
if (numericValue !== null) {
|
|
371
|
+
state.raw_value = numericValue
|
|
372
|
+
state.value_range = max !== null && max > 0 ? { min: 0, max } : null
|
|
373
|
+
state.value = max !== null && max > 0 ? Math.round((numericValue / max) * 100) : numericValue
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
const numericValue = parseNumberAttr(node['@_value'])
|
|
377
|
+
if (numericValue !== null) {
|
|
378
|
+
state.value = numericValue
|
|
379
|
+
state.raw_value = numericValue
|
|
380
|
+
} else if (textValue) {
|
|
381
|
+
state.value = textValue
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return Object.keys(state).length > 0 ? state : null
|
|
386
|
+
}
|
|
387
|
+
|
|
326
388
|
export async function getScreenResolution(deviceId?: string): Promise<{ width: number; height: number }> {
|
|
327
389
|
try {
|
|
328
390
|
const output = await execAdb(['shell', 'wm', 'size'], deviceId);
|
|
@@ -339,27 +401,29 @@ export function traverseNode(node: any, elements: UIElement[], parentIndex: numb
|
|
|
339
401
|
|
|
340
402
|
let currentIndex = -1;
|
|
341
403
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
404
|
+
if (node['@_class']) {
|
|
405
|
+
const text = node['@_text'] || null;
|
|
406
|
+
const contentDescription = node['@_content-desc'] || null;
|
|
407
|
+
const clickable = node['@_clickable'] === 'true';
|
|
408
|
+
const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
|
|
409
|
+
const state = extractAndroidState(node);
|
|
347
410
|
|
|
348
|
-
|
|
411
|
+
const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
|
|
349
412
|
|
|
350
|
-
|
|
351
|
-
|
|
413
|
+
if (isUseful) {
|
|
414
|
+
const element: UIElement = {
|
|
352
415
|
text,
|
|
353
416
|
contentDescription,
|
|
354
417
|
type: node['@_class'] || 'unknown',
|
|
355
418
|
resourceId: node['@_resource-id'] || null,
|
|
356
419
|
clickable,
|
|
357
420
|
enabled: node['@_enabled'] === 'true',
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
421
|
+
visible: true,
|
|
422
|
+
bounds,
|
|
423
|
+
center: getCenter(bounds),
|
|
424
|
+
depth,
|
|
425
|
+
state
|
|
426
|
+
};
|
|
363
427
|
|
|
364
428
|
if (parentIndex !== -1) {
|
|
365
429
|
element.parentId = parentIndex;
|