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.
- package/dist/interact/index.js +392 -192
- package/dist/observe/ios.js +47 -3
- package/dist/server/common.js +39 -0
- package/dist/server-core.js +1 -1
- package/dist/utils/android/utils.js +35 -3
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +114 -16
- package/docs/rfcs/009-semantic-control-modeling-for-custom-and-composite-controls.md +238 -0
- package/docs/rfcs/010-verification-stabilization-and-temporal-convergence.md +265 -0
- package/docs/rfcs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md +321 -0
- package/docs/rfcs/011.1-recovery-contract-types-and-runtime-wiring-spec.md +253 -0
- package/docs/rfcs/012.md +203 -0
- package/docs/specs/mcp-tooling-spec-v1.md +34 -0
- package/docs/tools/interact.md +10 -0
- package/package.json +1 -1
- package/src/interact/index.ts +433 -194
- package/src/observe/ios.ts +42 -3
- package/src/server/common.ts +44 -1
- package/src/server-core.ts +1 -1
- package/src/types.ts +41 -1
- package/src/utils/android/utils.ts +30 -3
- package/test/unit/interact/adjust_control.test.ts +77 -1
- package/test/unit/interact/verification_stabilization.test.ts +94 -0
- package/test/unit/observe/find_element.test.ts +46 -0
- package/test/unit/observe/state_extraction.test.ts +65 -2
- package/test/unit/server/common.test.ts +36 -1
package/src/observe/ios.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/server/common.ts
CHANGED
|
@@ -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 } : {})
|
package/src/server-core.ts
CHANGED
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
|
-
|
|
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.
|
|
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, {
|
|
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, {
|
|
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
|
|