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.
@@ -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)
@@ -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 traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: number = -1, depth: number = 0): number {
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) {
@@ -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,
@@ -13,7 +13,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
13
13
 
14
14
  export const serverInfo = {
15
15
  name: 'mobile-debug-mcp',
16
- version: '0.24.8'
16
+ version: '0.25.0'
17
17
  }
18
18
 
19
19
  export function createServer() {
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
- if (node['@_class']) {
343
- const text = node['@_text'] || null;
344
- const contentDescription = node['@_content-desc'] || null;
345
- const clickable = node['@_clickable'] === 'true';
346
- const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
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
- const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
411
+ const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
349
412
 
350
- if (isUseful) {
351
- const element: UIElement = {
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
- visible: true,
359
- bounds,
360
- center: getCenter(bounds),
361
- depth
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;