mobile-debug-mcp 0.26.4 → 0.27.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.
@@ -73,6 +73,9 @@ function normalizeIOSType(value: unknown): string {
73
73
 
74
74
  function inferIOSRole(type: string, traits: string[]): string | null {
75
75
  if (/slider|adjustable/.test(type) || traits.some((trait) => /adjustable|slider/.test(trait))) return 'slider'
76
+ if (/stepper/.test(type)) return 'stepper'
77
+ if (/picker|pop up button|dropdown/.test(type)) return 'dropdown'
78
+ if (/segmented control/.test(type)) return 'segmented_control'
76
79
  if (/button/.test(type) || traits.some((trait) => /button/.test(trait))) return 'button'
77
80
  if (/cell/.test(type)) return 'cell'
78
81
  if (/switch/.test(type)) return 'switch'
@@ -113,11 +116,47 @@ function buildIOSSelector(type: string, label: string | null, value: string | nu
113
116
  return null
114
117
  }
115
118
 
116
- function buildIOSSemantic(type: string, traits: string[]): UIElementSemanticMetadata {
117
- return {
119
+ function buildIOSSemantic(type: string, traits: string[], role: string | null, value: string | null): UIElementSemanticMetadata {
120
+ const semantic: UIElementSemanticMetadata = {
118
121
  is_clickable: traits.includes("UIAccessibilityTraitButton") || /adjustable|slider/.test(type) || type === "Button" || type === "Cell",
119
122
  is_container: /window|application|group|scroll view|collection view/.test(type)
120
123
  }
124
+
125
+ if (role === 'slider') {
126
+ semantic.semantic_role = 'slider'
127
+ semantic.adjustable = true
128
+ semantic.supported_actions = ['adjust']
129
+ semantic.state_shape = 'continuous'
130
+ } else if (role === 'stepper') {
131
+ semantic.semantic_role = 'stepper'
132
+ semantic.adjustable = true
133
+ semantic.supported_actions = ['increment', 'decrement']
134
+ semantic.state_shape = 'discrete'
135
+ } else if (role === 'dropdown') {
136
+ semantic.semantic_role = 'dropdown'
137
+ semantic.supported_actions = ['tap', 'expand']
138
+ semantic.state_shape = 'semantic'
139
+ } else if (role === 'segmented_control') {
140
+ semantic.semantic_role = 'segmented_control'
141
+ semantic.supported_actions = ['tap']
142
+ semantic.state_shape = 'discrete'
143
+ } else if (traits.some((trait) => /adjustable|slider/i.test(trait)) || /adjustable|slider/.test(type)) {
144
+ semantic.semantic_role = 'custom_adjustable'
145
+ semantic.adjustable = true
146
+ semantic.supported_actions = ['adjust']
147
+ semantic.state_shape = 'continuous'
148
+ } else if (semantic.is_clickable) {
149
+ semantic.supported_actions = ['tap']
150
+ }
151
+
152
+ if (semantic.state_shape === undefined && semantic.adjustable && value !== null) {
153
+ const numericValue = parseIOSNumber(value)
154
+ if (numericValue !== null && numericValue >= 0 && numericValue <= 1) {
155
+ semantic.state_shape = 'continuous'
156
+ }
157
+ }
158
+
159
+ return semantic
121
160
  }
122
161
 
123
162
  function isIOSAdjustable(node: IDBElement, type: string, traits: string[]): boolean {
@@ -184,8 +223,8 @@ export function traverseIDBNode(node: IDBElement, elements: UIElement[], parentI
184
223
  const normalizedType = normalizeIOSType(type)
185
224
  const stableId = getIOSStableId(node)
186
225
  const selector = buildIOSSelector(type, label, value, stableId)
187
- const semantic = buildIOSSemantic(normalizedType, traits)
188
226
  const role = inferIOSRole(normalizedType, traits)
227
+ const semantic = buildIOSSemantic(normalizedType, traits, role, value)
189
228
 
190
229
  const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell";
191
230
 
@@ -1,10 +1,15 @@
1
1
  import type {
2
2
  ActionExecutionResult,
3
3
  ActionFailureCode,
4
- ActionTargetResolved
4
+ ActionTargetResolved,
5
+ FailureClass,
6
+ RecoveryState
5
7
  } from '../types.js'
6
8
  import { ToolsObserve } from '../observe/index.js'
7
9
 
10
+ export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3
11
+ export const DEFAULT_MAX_RETRY_DEPTH = 3
12
+
8
13
  export function wrapResponse<T>(data: T) {
9
14
  return {
10
15
  content: [{
@@ -103,6 +108,7 @@ export function normalizeResolvedTarget(value: Partial<ActionTargetResolved> | n
103
108
 
104
109
  export function inferGenericFailure(message: string | undefined): { failureCode: ActionFailureCode; retryable: boolean } {
105
110
  if (message && /timeout/i.test(message)) return { failureCode: 'TIMEOUT', retryable: true }
111
+ if (message && /semantic mismatch/i.test(message)) return { failureCode: 'SEMANTIC_MISMATCH', retryable: false }
106
112
  return { failureCode: 'UNKNOWN', retryable: false }
107
113
  }
108
114
 
@@ -129,6 +135,42 @@ export function determineActionLifecycleState({
129
135
  return ACTION_LIFECYCLE_STATE_BY_OUTCOME.success
130
136
  }
131
137
 
138
+ function mapFailureCodeToFailureClass(code: ActionFailureCode): FailureClass {
139
+ switch (code) {
140
+ case 'ELEMENT_NOT_FOUND':
141
+ case 'AMBIGUOUS_TARGET':
142
+ case 'STALE_REFERENCE':
143
+ return 'TargetResolutionFailure'
144
+ case 'ELEMENT_NOT_INTERACTABLE':
145
+ return 'ExecutionFailure'
146
+ case 'TIMEOUT':
147
+ case 'ACTION_REJECTED':
148
+ case 'NAVIGATION_NO_CHANGE':
149
+ case 'UNKNOWN':
150
+ return 'ExecutionFailure'
151
+ case 'VERIFICATION_FAILED':
152
+ case 'EXPECT_STATE_MISMATCH':
153
+ return 'VerificationFailure'
154
+ case 'CONTROL_CONVERGENCE_FAILED':
155
+ return 'ControlConvergenceFailure'
156
+ case 'SEMANTIC_MISMATCH':
157
+ return 'SemanticMismatchFailure'
158
+ }
159
+ }
160
+
161
+ function buildRecoveryState(failureCode: ActionFailureCode, retryable: boolean): RecoveryState {
162
+ return {
163
+ failure_class: mapFailureCodeToFailureClass(failureCode),
164
+ runtime_code: failureCode,
165
+ recovery_attempts: 0,
166
+ max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
167
+ retry_depth: 0,
168
+ max_retry_depth: DEFAULT_MAX_RETRY_DEPTH,
169
+ is_terminal: false,
170
+ retry_allowed: retryable
171
+ }
172
+ }
173
+
132
174
  export function buildActionExecutionResult({
133
175
  actionType,
134
176
  device,
@@ -167,6 +209,7 @@ export function buildActionExecutionResult({
167
209
  },
168
210
  success,
169
211
  ...(failure ? { failure_code: failure.failureCode, retryable: failure.retryable } : {}),
212
+ ...(failure ? { recovery: buildRecoveryState(failure.failureCode, failure.retryable) } : {}),
170
213
  ui_fingerprint_before: uiFingerprintBefore,
171
214
  ui_fingerprint_after: uiFingerprintAfter,
172
215
  ...(details ? { details } : {})
@@ -13,7 +13,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
13
13
 
14
14
  export const serverInfo = {
15
15
  name: 'mobile-debug-mcp',
16
- version: '0.26.4'
16
+ version: '0.27.0'
17
17
  }
18
18
 
19
19
  export function createServer() {
package/src/types.ts CHANGED
@@ -107,7 +107,25 @@ export interface UIResolutionSelector {
107
107
  export interface UIElementSemanticMetadata {
108
108
  is_clickable: boolean;
109
109
  is_container: boolean;
110
- }
110
+ semantic_role?: 'slider' | 'stepper' | 'dropdown' | 'segmented_control' | 'custom_adjustable' | 'composite_control' | null;
111
+ supported_actions?: string[] | null;
112
+ adjustable?: boolean | null;
113
+ state_shape?: 'continuous' | 'discrete' | 'semantic' | null;
114
+ }
115
+
116
+ export type FailureClass =
117
+ | 'TargetResolutionFailure'
118
+ | 'ExecutionFailure'
119
+ | 'VerificationFailure'
120
+ | 'ControlConvergenceFailure'
121
+ | 'SemanticMismatchFailure';
122
+
123
+ export type RecoveryStrategy =
124
+ | 're_resolve'
125
+ | 'alternate_candidate'
126
+ | 'state_refresh'
127
+ | 'retry_adjustment'
128
+ | 'step_back';
111
129
 
112
130
  export interface LoadingState {
113
131
  active: boolean;
@@ -236,8 +254,25 @@ export type ActionFailureCode =
236
254
  | 'NAVIGATION_NO_CHANGE'
237
255
  | 'AMBIGUOUS_TARGET'
238
256
  | 'STALE_REFERENCE'
257
+ | 'ACTION_REJECTED'
258
+ | 'VERIFICATION_FAILED'
259
+ | 'EXPECT_STATE_MISMATCH'
260
+ | 'CONTROL_CONVERGENCE_FAILED'
261
+ | 'SEMANTIC_MISMATCH'
239
262
  | 'UNKNOWN'
240
263
 
264
+ export interface RecoveryState {
265
+ failure_class: FailureClass;
266
+ runtime_code: ActionFailureCode;
267
+ recovery_strategy?: RecoveryStrategy;
268
+ recovery_attempts: number;
269
+ max_recovery_attempts: number;
270
+ retry_depth: number;
271
+ max_retry_depth: number;
272
+ is_terminal: boolean;
273
+ retry_allowed?: boolean;
274
+ }
275
+
241
276
  export interface ActionTargetResolved {
242
277
  elementId: string | null;
243
278
  text: string | null;
@@ -341,6 +376,7 @@ export interface ActionExecutionResult {
341
376
  success: boolean;
342
377
  failure_code?: ActionFailureCode;
343
378
  retryable?: boolean;
379
+ recovery?: RecoveryState;
344
380
  ui_fingerprint_before?: string | null;
345
381
  ui_fingerprint_after?: string | null;
346
382
  details?: Record<string, unknown>;
@@ -411,6 +447,10 @@ export interface ExpectStateResponse {
411
447
  reason?: string;
412
448
  failure_code?: 'ELEMENT_NOT_FOUND' | 'UNKNOWN';
413
449
  retryable?: boolean;
450
+ stabilization_attempts?: number;
451
+ stabilization_window_ms?: number;
452
+ stable_observation_count?: number;
453
+ snapshot_freshness_ms?: number;
414
454
  }
415
455
 
416
456
  export interface AdjustControlResponse extends ActionExecutionResult {
@@ -342,6 +342,9 @@ function normalizeClassName(value: unknown): string {
342
342
 
343
343
  function inferAndroidRole(className: string): string | null {
344
344
  if (/seekbar|slider/.test(className)) return 'slider'
345
+ if (/stepper|numberpicker/.test(className)) return 'stepper'
346
+ if (/spinner|dropdown/.test(className)) return 'dropdown'
347
+ if (/segment|tablayout/.test(className)) return 'segmented_control'
345
348
  if (/switch|toggle/.test(className)) return 'switch'
346
349
  if (/checkbox/.test(className)) return 'checkbox'
347
350
  if (/radiobutton|radio/.test(className)) return 'radio'
@@ -375,11 +378,35 @@ function buildAndroidSelector(text: string | null, contentDescription: string |
375
378
  return null
376
379
  }
377
380
 
378
- function buildAndroidSemantic(clickable: boolean, className: string): UIElementSemanticMetadata {
379
- return {
381
+ function buildAndroidSemantic(clickable: boolean, className: string, role: string | null): UIElementSemanticMetadata {
382
+ const semantic: UIElementSemanticMetadata = {
380
383
  is_clickable: clickable,
381
384
  is_container: /recyclerview|scroll|layout|viewgroup|frame/.test(className)
382
385
  }
386
+
387
+ if (role === 'slider') {
388
+ semantic.semantic_role = 'slider'
389
+ semantic.adjustable = true
390
+ semantic.supported_actions = ['adjust']
391
+ semantic.state_shape = 'continuous'
392
+ } else if (role === 'stepper') {
393
+ semantic.semantic_role = 'stepper'
394
+ semantic.adjustable = true
395
+ semantic.supported_actions = ['increment', 'decrement']
396
+ semantic.state_shape = 'discrete'
397
+ } else if (role === 'dropdown') {
398
+ semantic.semantic_role = 'dropdown'
399
+ semantic.supported_actions = ['tap', 'expand']
400
+ semantic.state_shape = 'semantic'
401
+ } else if (role === 'segmented_control') {
402
+ semantic.semantic_role = 'segmented_control'
403
+ semantic.supported_actions = ['tap']
404
+ semantic.state_shape = 'discrete'
405
+ } else if (clickable) {
406
+ semantic.supported_actions = ['tap']
407
+ }
408
+
409
+ return semantic
383
410
  }
384
411
 
385
412
  function isSliderLikeAndroid(node: any): boolean {
@@ -459,7 +486,7 @@ export function traverseNode(node: any, elements: UIElement[], parentIndex: numb
459
486
  const stableId = resourceId ?? (typeof contentDescription === 'string' && contentDescription.trim().length > 0 ? contentDescription : null)
460
487
  const testTag = stableId
461
488
  const selector = buildAndroidSelector(text, contentDescription, resourceId, normalizeClassName(className))
462
- const semantic = buildAndroidSemantic(clickable, normalizeClassName(className))
489
+ const semantic = buildAndroidSemantic(clickable, normalizeClassName(className), role)
463
490
 
464
491
  const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
465
492
 
@@ -346,9 +346,85 @@ async function run() {
346
346
  assert.strictEqual(cachedResolveAdjust.success, true)
347
347
  assert.strictEqual(cachedResolveAdjust.converged, true)
348
348
  assert.strictEqual(cachedResolveAdjust.within_tolerance, true)
349
- assert.strictEqual(cachedResolveAdjust.attempts, 3)
349
+ assert.ok(cachedResolveAdjust.attempts >= 3)
350
350
  assert.strictEqual(treeFetches, 1, 'second attempt should reuse the resolved element instead of refetching the UI tree')
351
351
 
352
+ const probeTapStart = tapCalls.length
353
+ const probeSwipeStart = swipeCalls.length
354
+ let probeVerificationCount = 0
355
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
356
+ device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
357
+ screen: '',
358
+ resolution: { width: 1080, height: 2400 },
359
+ elements: [
360
+ {
361
+ text: 'Duration',
362
+ type: 'android.widget.SeekBar',
363
+ contentDescription: null,
364
+ clickable: true,
365
+ enabled: true,
366
+ visible: true,
367
+ bounds: [0, 0, 200, 40],
368
+ resourceId: 'seek_duration',
369
+ state: {
370
+ value: 10,
371
+ raw_value: 10,
372
+ value_range: { min: 0, max: 20 }
373
+ }
374
+ }
375
+ ]
376
+ })
377
+
378
+ ;(ToolsInteract as any).tapHandler = async ({ platform, x, y, deviceId }: any) => {
379
+ tapCalls.push({ platform, x, y, deviceId })
380
+ return {
381
+ device: { platform: platform || 'android', id: deviceId || 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
382
+ success: true,
383
+ x,
384
+ y
385
+ }
386
+ }
387
+
388
+ ;(ToolsInteract as any).expectStateHandler = async () => {
389
+ probeVerificationCount++
390
+ const value = probeVerificationCount === 1 ? 11 : 12
391
+ return {
392
+ success: true,
393
+ selector: { text: 'Duration' },
394
+ element_id: wait.element.elementId,
395
+ expected_state: { property: 'value', expected: 12 },
396
+ element: {
397
+ elementId: wait.element.elementId,
398
+ text: 'Duration',
399
+ resource_id: 'seek_duration',
400
+ accessibility_id: null,
401
+ class: 'android.widget.SeekBar',
402
+ bounds: [0, 0, 200, 40],
403
+ index: 0,
404
+ state: { value, raw_value: value, value_range: { min: 0, max: 20 } }
405
+ },
406
+ observed_state: { property: 'value', value, raw_value: value },
407
+ reason: value === 12 ? 'value matches expected value' : 'value still below target'
408
+ }
409
+ }
410
+
411
+ const probeAdjust = await ToolsInteract.adjustControlHandler({
412
+ element_id: wait.element.elementId,
413
+ property: 'value',
414
+ targetValue: 12,
415
+ tolerance: 0.5,
416
+ maxAttempts: 3,
417
+ platform: 'android'
418
+ })
419
+
420
+ assert.strictEqual(probeAdjust.success, true)
421
+ assert.strictEqual(probeAdjust.converged, true)
422
+ assert.strictEqual(probeAdjust.within_tolerance, true)
423
+ assert.strictEqual(probeAdjust.adjustment_mode, 'coordinate')
424
+ assert.strictEqual(probeAdjust.attempts, 2)
425
+ assert.strictEqual(tapCalls.length, probeTapStart + 2)
426
+ assert.strictEqual(swipeCalls.length, probeSwipeStart)
427
+
352
428
  console.log('adjust_control unit tests passed')
353
429
  } finally {
354
430
  ;(Observe as any).ToolsObserve.getUITreeHandler = originalGetUITreeHandler
@@ -0,0 +1,94 @@
1
+ import assert from 'assert'
2
+ import { ToolsInteract } from '../../../src/interact/index.js'
3
+ import * as Observe from '../../../src/observe/index.js'
4
+
5
+ async function run() {
6
+ console.log('Starting verification stabilization unit tests...')
7
+
8
+ const originalGetUITreeHandler = (Observe as any).ToolsObserve.getUITreeHandler
9
+
10
+ try {
11
+ let visibilityCalls = 0
12
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => {
13
+ visibilityCalls++
14
+ const visible = visibilityCalls >= 3
15
+ return {
16
+ device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
17
+ captured_at_ms: Date.now(),
18
+ resolution: { width: 1080, height: 2400 },
19
+ elements: [
20
+ {
21
+ text: visible ? 'Ready' : 'Loading',
22
+ contentDescription: null,
23
+ type: 'android.widget.TextView',
24
+ resourceId: 'ready_label',
25
+ clickable: false,
26
+ enabled: true,
27
+ visible,
28
+ bounds: [0, 0, 120, 40]
29
+ }
30
+ ]
31
+ }
32
+ }
33
+
34
+ const visibleResult = await ToolsInteract.waitForUIHandler({
35
+ selector: { text: 'Ready' },
36
+ condition: 'visible',
37
+ timeout_ms: 600,
38
+ poll_interval_ms: 50,
39
+ platform: 'android'
40
+ })
41
+
42
+ assert.strictEqual(visibleResult.status, 'success')
43
+ assert.ok(visibilityCalls >= 4, 'visibility should be confirmed across consecutive reads')
44
+
45
+ let stateCalls = 0
46
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => {
47
+ stateCalls++
48
+ const value = stateCalls >= 3 ? 30 : 10
49
+ const stale = stateCalls === 2
50
+ return {
51
+ device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
52
+ captured_at_ms: stale ? Date.now() - 1000 : Date.now(),
53
+ resolution: { width: 1080, height: 2400 },
54
+ elements: [
55
+ {
56
+ text: 'Volume',
57
+ contentDescription: null,
58
+ type: 'android.widget.SeekBar',
59
+ resourceId: 'volume_slider',
60
+ clickable: true,
61
+ enabled: true,
62
+ visible: true,
63
+ bounds: [0, 0, 240, 40],
64
+ state: {
65
+ value,
66
+ raw_value: value,
67
+ value_range: { min: 0, max: 100 }
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ }
73
+
74
+ const stateResult = await ToolsInteract.expectStateHandler({
75
+ selector: { text: 'Volume' },
76
+ property: 'value',
77
+ expected: 30,
78
+ platform: 'android'
79
+ })
80
+
81
+ assert.strictEqual(stateResult.success, true)
82
+ assert.strictEqual(stateResult.reason, 'value matches expected value')
83
+ assert.ok(stateCalls >= 4, 'state verification should require stable fresh reads')
84
+
85
+ console.log('verification stabilization unit tests passed')
86
+ } finally {
87
+ ;(Observe as any).ToolsObserve.getUITreeHandler = originalGetUITreeHandler
88
+ }
89
+ }
90
+
91
+ run().catch((error) => {
92
+ console.error(error)
93
+ process.exit(1)
94
+ })
@@ -78,6 +78,52 @@ async function run() {
78
78
  assert.ok((res4.resolution?.alternates || []).length >= 1, 'Parent promotion should preserve alternates')
79
79
  process.stdout.write('Test 4: ' + (pass4 ? 'PASS' : 'FAIL') + '\n');
80
80
 
81
+ // Test 4b: semantic-only stepper should be discoverable by supported action
82
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
83
+ device: { platform: 'android', id: 'mock' },
84
+ screen: '',
85
+ resolution: { width: 1080, height: 1920 },
86
+ elements: [
87
+ {
88
+ text: null,
89
+ contentDescription: 'Quantity stepper',
90
+ type: 'android.widget.NumberPicker',
91
+ clickable: false,
92
+ enabled: true,
93
+ visible: true,
94
+ bounds: [10,10,200,80],
95
+ resourceId: 'picker_quantity',
96
+ semantic: {
97
+ is_clickable: false,
98
+ is_container: true,
99
+ semantic_role: 'stepper',
100
+ supported_actions: ['increment', 'decrement'],
101
+ adjustable: true,
102
+ state_shape: 'discrete'
103
+ }
104
+ }
105
+ ]
106
+ })
107
+
108
+ const res4b: any = await ToolsInteract.findElementHandler({ query: 'increment', exact: false, platform: 'android', timeoutMs: 300 })
109
+ process.stdout.write('res4b ' + JSON.stringify(res4b, null, 2) + '\n');
110
+ const pass4b = res4b.found === true && res4b.element && res4b.element.resourceId === 'picker_quantity' && res4b.element.semantic?.semantic_role === 'stepper'
111
+ assert.ok(pass4b, 'Semantic-only steppers should be discoverable by supported actions')
112
+ assert.strictEqual(res4b.resolution?.reason, 'semantic_action_match')
113
+ process.stdout.write('Test 4b: ' + (pass4b ? 'PASS' : 'FAIL') + '\n');
114
+
115
+ const res4bb: any = await ToolsInteract.findElementHandler({ query: 'increment', exact: true, platform: 'android', timeoutMs: 300 })
116
+ process.stdout.write('res4bb ' + JSON.stringify(res4bb, null, 2) + '\n');
117
+ const pass4bb = res4bb.found === true && res4bb.element && res4bb.element.resourceId === 'picker_quantity' && res4bb.resolution?.reason === 'semantic_action_match'
118
+ assert.ok(pass4bb, 'Exact searches should still match exact semantic actions')
119
+ process.stdout.write('Test 4bb: ' + (pass4bb ? 'PASS' : 'FAIL') + '\n');
120
+
121
+ const res4c: any = await ToolsInteract.findElementHandler({ query: 'control', exact: true, platform: 'android', timeoutMs: 300 })
122
+ process.stdout.write('res4c ' + JSON.stringify(res4c, null, 2) + '\n');
123
+ const pass4c = res4c.found === false
124
+ assert.ok(pass4c, 'Exact searches should not fall back to broad semantic keywords')
125
+ process.stdout.write('Test 4c: ' + (pass4c ? 'PASS' : 'FAIL') + '\n');
126
+
81
127
  // Test 5: duration label should resolve to the nearby slider control
82
128
  ;(ToolsObserve as any).getUITreeHandler = async () => ({
83
129
  device: { platform: 'android', id: 'mock' },
@@ -29,7 +29,14 @@ async function run() {
29
29
  value: 'com.example:id/duration',
30
30
  confidence: { score: 1, reason: 'resource_id' }
31
31
  })
32
- assert.deepStrictEqual(androidElements[0].semantic, { is_clickable: true, is_container: false })
32
+ assert.deepStrictEqual(androidElements[0].semantic, {
33
+ is_clickable: true,
34
+ is_container: false,
35
+ semantic_role: 'slider',
36
+ supported_actions: ['adjust'],
37
+ adjustable: true,
38
+ state_shape: 'continuous'
39
+ })
33
40
 
34
41
  const androidProgressElements: any[] = []
35
42
  traverseNode({
@@ -44,6 +51,21 @@ async function run() {
44
51
 
45
52
  assert.notStrictEqual(androidProgressElements[0]?.role, 'slider')
46
53
  assert.notStrictEqual(androidProgressElements[0]?.state?.value, 40)
54
+ assert.notStrictEqual(androidProgressElements[0]?.semantic?.adjustable, true)
55
+
56
+ const androidStepperElements: any[] = []
57
+ traverseNode({
58
+ '@_class': 'android.widget.NumberPicker',
59
+ '@_text': 'Quantity',
60
+ '@_content-desc': 'Quantity stepper',
61
+ '@_clickable': 'false',
62
+ '@_enabled': 'true',
63
+ '@_bounds': '[0,0][200,80]'
64
+ }, androidStepperElements)
65
+ assert.strictEqual(androidStepperElements[0].role, 'stepper')
66
+ assert.deepStrictEqual(androidStepperElements[0].semantic?.semantic_role, 'stepper')
67
+ assert.deepStrictEqual(androidStepperElements[0].semantic?.supported_actions, ['increment', 'decrement'])
68
+ assert.strictEqual(androidStepperElements[0].semantic?.adjustable, true)
47
69
 
48
70
  const androidFallbackElements: any[] = []
49
71
  traverseNode({
@@ -82,7 +104,14 @@ async function run() {
82
104
  value: 'playback_speed_slider',
83
105
  confidence: { score: 1, reason: 'accessibility_identifier' }
84
106
  })
85
- assert.deepStrictEqual(iosElements[0].semantic, { is_clickable: true, is_container: false })
107
+ assert.deepStrictEqual(iosElements[0].semantic, {
108
+ is_clickable: true,
109
+ is_container: false,
110
+ semantic_role: 'slider',
111
+ supported_actions: ['adjust'],
112
+ adjustable: true,
113
+ state_shape: 'continuous'
114
+ })
86
115
 
87
116
  const iosProgressElements: any[] = []
88
117
  traverseIDBNode({
@@ -94,6 +123,40 @@ async function run() {
94
123
 
95
124
  assert.notStrictEqual(iosProgressElements[0]?.role, 'slider')
96
125
 
126
+ const iosStepperElements: any[] = []
127
+ traverseIDBNode({
128
+ AXElementType: 'Stepper',
129
+ AXLabel: 'Quantity',
130
+ AXValue: '1',
131
+ AXTraits: ['UIAccessibilityTraitAdjustable']
132
+ }, iosStepperElements)
133
+ assert.strictEqual(iosStepperElements[0].role, 'stepper')
134
+ assert.strictEqual(iosStepperElements[0].semantic?.semantic_role, 'stepper')
135
+ assert.deepStrictEqual(iosStepperElements[0].semantic?.supported_actions, ['increment', 'decrement'])
136
+ assert.strictEqual(iosStepperElements[0].semantic?.state_shape, 'discrete')
137
+
138
+ const iosSegmentedElements: any[] = []
139
+ traverseIDBNode({
140
+ AXElementType: 'Segmented Control',
141
+ AXLabel: 'Playback mode',
142
+ AXTraits: ['UIAccessibilityTraitButton']
143
+ }, iosSegmentedElements)
144
+ assert.strictEqual(iosSegmentedElements[0].role, 'segmented_control')
145
+ assert.strictEqual(iosSegmentedElements[0].semantic?.semantic_role, 'segmented_control')
146
+ assert.deepStrictEqual(iosSegmentedElements[0].semantic?.supported_actions, ['tap'])
147
+
148
+ const iosCustomAdjustableElements: any[] = []
149
+ traverseIDBNode({
150
+ AXElementType: 'CustomControl',
151
+ AXLabel: 'Intensity',
152
+ AXValue: '0.25',
153
+ AXTraits: ['UIAccessibilityTraitAdjustable']
154
+ }, iosCustomAdjustableElements)
155
+ assert.strictEqual(iosCustomAdjustableElements[0].semantic?.semantic_role, 'custom_adjustable')
156
+ assert.strictEqual(iosCustomAdjustableElements[0].semantic?.adjustable, true)
157
+ assert.deepStrictEqual(iosCustomAdjustableElements[0].semantic?.supported_actions, ['adjust'])
158
+ assert.strictEqual(iosCustomAdjustableElements[0].semantic?.state_shape, 'continuous')
159
+
97
160
  const iosFallbackElements: any[] = []
98
161
  traverseIDBNode({
99
162
  AXElementType: 'Button',
@@ -1,5 +1,5 @@
1
1
  import assert from 'assert'
2
- import { requireBooleanArg } from '../../../src/server/common.js'
2
+ import { buildActionExecutionResult, inferGenericFailure, requireBooleanArg } from '../../../src/server/common.js'
3
3
 
4
4
  function run() {
5
5
  assert.strictEqual(requireBooleanArg({ exact: true }, 'exact'), true)
@@ -7,6 +7,41 @@ function run() {
7
7
  assert.throws(() => requireBooleanArg({}, 'exact'), /Missing or invalid boolean argument: exact/)
8
8
  assert.throws(() => requireBooleanArg({ exact: 'true' as unknown as boolean }, 'exact'), /Missing or invalid boolean argument: exact/)
9
9
 
10
+ assert.deepStrictEqual(inferGenericFailure('semantic mismatch between inferred and raw state'), {
11
+ failureCode: 'SEMANTIC_MISMATCH',
12
+ retryable: false
13
+ })
14
+
15
+ const recoveryResult = buildActionExecutionResult({
16
+ actionType: 'tap',
17
+ sourceModule: 'server',
18
+ selector: { x: 10, y: 20 },
19
+ success: false,
20
+ uiFingerprintBefore: 'fp_before',
21
+ uiFingerprintAfter: 'fp_after',
22
+ failure: { failureCode: 'SEMANTIC_MISMATCH', retryable: false }
23
+ })
24
+ assert.strictEqual(recoveryResult.failure_code, 'SEMANTIC_MISMATCH')
25
+ assert.strictEqual(recoveryResult.recovery?.failure_class, 'SemanticMismatchFailure')
26
+ assert.strictEqual(recoveryResult.recovery?.runtime_code, 'SEMANTIC_MISMATCH')
27
+ assert.strictEqual(recoveryResult.recovery?.retry_allowed, false)
28
+ assert.strictEqual(recoveryResult.recovery?.max_recovery_attempts, 3)
29
+ assert.strictEqual(recoveryResult.recovery?.max_retry_depth, 3)
30
+
31
+ const notInteractableResult = buildActionExecutionResult({
32
+ actionType: 'tap',
33
+ sourceModule: 'server',
34
+ selector: { x: 5, y: 5 },
35
+ success: false,
36
+ uiFingerprintBefore: 'fp_before',
37
+ uiFingerprintAfter: 'fp_after',
38
+ failure: { failureCode: 'ELEMENT_NOT_INTERACTABLE', retryable: true }
39
+ })
40
+ assert.strictEqual(notInteractableResult.failure_code, 'ELEMENT_NOT_INTERACTABLE')
41
+ assert.strictEqual(notInteractableResult.recovery?.failure_class, 'ExecutionFailure')
42
+ assert.strictEqual(notInteractableResult.recovery?.runtime_code, 'ELEMENT_NOT_INTERACTABLE')
43
+ assert.strictEqual(notInteractableResult.recovery?.retry_allowed, true)
44
+
10
45
  console.log('server common tests passed')
11
46
  }
12
47