mobile-debug-mcp 0.25.0 → 0.26.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 +143 -4
- package/dist/observe/android.js +10 -1
- package/dist/observe/index.js +19 -1
- package/dist/observe/ios.js +86 -3
- package/dist/observe/snapshot-metadata.js +88 -0
- package/dist/server/tool-definitions.js +30 -2
- package/dist/server/tool-handlers.js +10 -0
- package/dist/server-core.js +1 -1
- package/dist/utils/android/utils.js +68 -3
- package/docs/CHANGELOG.md +12 -0
- package/docs/ROADMAP.md +19 -1
- package/docs/rfcs/002-richer-element-identity +400 -0
- package/docs/rfcs/003-wait-and-synchronization-reliability.md +296 -0
- package/docs/specs/mcp-tooling-spec-v1.md +9 -0
- package/docs/tools/interact.md +21 -0
- package/docs/tools/observe.md +5 -2
- package/package.json +1 -1
- package/skills/rfc-review/SKILL.md +52 -0
- package/skills/rfc-review/references/rfc-review-checklist.md +12 -0
- package/skills/rfc-review/references/rfc-review-template.md +28 -0
- package/src/interact/index.ts +186 -4
- package/src/observe/android.ts +11 -1
- package/src/observe/index.ts +32 -1
- package/src/observe/ios.ts +97 -16
- package/src/observe/snapshot-metadata.ts +107 -0
- package/src/server/tool-definitions.ts +30 -2
- package/src/server/tool-handlers.ts +11 -0
- package/src/server-core.ts +1 -1
- package/src/types.ts +49 -1
- package/src/utils/android/utils.ts +78 -20
- package/test/unit/interact/wait_for_ui_change.test.ts +76 -0
- package/test/unit/observe/state_extraction.test.ts +47 -0
- package/test/unit/server/response_shapes.test.ts +37 -3
|
@@ -240,7 +240,7 @@ Failure Handling:
|
|
|
240
240
|
},
|
|
241
241
|
{
|
|
242
242
|
name: 'capture_debug_snapshot',
|
|
243
|
-
description: 'Capture a complete debug snapshot (raw observation layer plus optional derived semantic layer). Returns structured JSON.',
|
|
243
|
+
description: 'Capture a complete debug snapshot (raw observation layer plus optional derived semantic layer). Returns structured JSON with snapshot_revision, captured_at_ms, and loading_state when detectable.',
|
|
244
244
|
inputSchema: {
|
|
245
245
|
type: 'object',
|
|
246
246
|
properties: {
|
|
@@ -291,7 +291,7 @@ Failure Handling:
|
|
|
291
291
|
},
|
|
292
292
|
{
|
|
293
293
|
name: 'get_ui_tree',
|
|
294
|
-
description: 'Get the current UI hierarchy from an Android device or iOS simulator. Returns a structured JSON representation of the screen content.',
|
|
294
|
+
description: 'Get the current UI hierarchy from an Android device or iOS simulator. Returns a structured JSON representation of the screen content with snapshot metadata when available.',
|
|
295
295
|
inputSchema: {
|
|
296
296
|
type: 'object',
|
|
297
297
|
properties: {
|
|
@@ -363,6 +363,34 @@ Recommended Usage:
|
|
|
363
363
|
required: ['previousFingerprint']
|
|
364
364
|
}
|
|
365
365
|
},
|
|
366
|
+
{
|
|
367
|
+
name: 'wait_for_ui_change',
|
|
368
|
+
description: `Purpose:
|
|
369
|
+
Wait for a non-navigation UI mutation or in-place update to become stable.
|
|
370
|
+
|
|
371
|
+
Inputs:
|
|
372
|
+
- expected_change (optional): hierarchy_diff, text_change, or state_change
|
|
373
|
+
- timeout_ms (optional)
|
|
374
|
+
- stability_window_ms (optional)
|
|
375
|
+
|
|
376
|
+
Guidance:
|
|
377
|
+
- Prefer wait_for_screen_change for navigation transitions.
|
|
378
|
+
- Prefer wait_for_ui_change for in-place mutations and non-navigation updates.
|
|
379
|
+
- Use the returned snapshot_revision as the observed synchronization point when available.
|
|
380
|
+
|
|
381
|
+
Failure Handling:
|
|
382
|
+
- TIMEOUT means the UI did not change in a stable way within the allotted time.`,
|
|
383
|
+
inputSchema: {
|
|
384
|
+
type: 'object',
|
|
385
|
+
properties: {
|
|
386
|
+
platform: { type: 'string', enum: ['android', 'ios'], description: 'Optional platform override (android|ios)' },
|
|
387
|
+
deviceId: { type: 'string', description: 'Optional device id/udid to target' },
|
|
388
|
+
expected_change: { type: 'string', enum: ['hierarchy_diff', 'text_change', 'state_change'], description: 'Optional type of UI change to wait for' },
|
|
389
|
+
timeout_ms: { type: 'number', description: 'Timeout in ms to wait for change (default 60000)', default: 60000 },
|
|
390
|
+
stability_window_ms: { type: 'number', description: 'How long the change must remain stable before success (default 250)', default: 250 }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
},
|
|
366
394
|
{
|
|
367
395
|
name: 'expect_screen',
|
|
368
396
|
description: `Purpose:
|
|
@@ -288,6 +288,16 @@ async function handleWaitForUI(args: ToolCallArgs) {
|
|
|
288
288
|
return wrapResponse(res)
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
async function handleWaitForUIChange(args: ToolCallArgs) {
|
|
292
|
+
const platform = getStringArg(args, 'platform') as PlatformArg | undefined
|
|
293
|
+
const deviceId = getStringArg(args, 'deviceId')
|
|
294
|
+
const timeout_ms = getNumberArg(args, 'timeout_ms') ?? 60000
|
|
295
|
+
const stability_window_ms = getNumberArg(args, 'stability_window_ms') ?? 250
|
|
296
|
+
const expected_change = getStringArg(args, 'expected_change') as 'hierarchy_diff' | 'text_change' | 'state_change' | undefined
|
|
297
|
+
const res = await ToolsInteract.waitForUIChangeHandler({ platform, deviceId, timeout_ms, stability_window_ms, expected_change })
|
|
298
|
+
return wrapResponse(res)
|
|
299
|
+
}
|
|
300
|
+
|
|
291
301
|
async function handleFindElement(args: ToolCallArgs) {
|
|
292
302
|
const query = requireStringArg(args, 'query')
|
|
293
303
|
const exact = getBooleanArg(args, 'exact') ?? false
|
|
@@ -473,6 +483,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
|
|
|
473
483
|
get_current_screen: handleGetCurrentScreen,
|
|
474
484
|
get_screen_fingerprint: handleGetScreenFingerprint,
|
|
475
485
|
wait_for_screen_change: handleWaitForScreenChange,
|
|
486
|
+
wait_for_ui_change: handleWaitForUIChange,
|
|
476
487
|
expect_screen: handleExpectScreen,
|
|
477
488
|
expect_element_visible: handleExpectElementVisible,
|
|
478
489
|
expect_state: handleExpectState,
|
package/src/server-core.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -94,6 +94,27 @@ export interface UIElementState {
|
|
|
94
94
|
} | null;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
export interface SelectorConfidence {
|
|
98
|
+
score: number;
|
|
99
|
+
reason: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface UIResolutionSelector {
|
|
103
|
+
value: string | null;
|
|
104
|
+
confidence: SelectorConfidence | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface UIElementSemanticMetadata {
|
|
108
|
+
is_clickable: boolean;
|
|
109
|
+
is_container: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface LoadingState {
|
|
113
|
+
active: boolean;
|
|
114
|
+
signal: string;
|
|
115
|
+
source: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
export interface CaptureAndroidScreenResponse {
|
|
98
119
|
device: DeviceInfo;
|
|
99
120
|
screenshot: string; // base64 encoded string
|
|
@@ -132,6 +153,11 @@ export interface UIElement {
|
|
|
132
153
|
center?: [number, number];
|
|
133
154
|
depth?: number;
|
|
134
155
|
state?: UIElementState | null;
|
|
156
|
+
stable_id?: string | null;
|
|
157
|
+
role?: string | null;
|
|
158
|
+
test_tag?: string | null;
|
|
159
|
+
selector?: UIResolutionSelector | null;
|
|
160
|
+
semantic?: UIElementSemanticMetadata | null;
|
|
135
161
|
}
|
|
136
162
|
|
|
137
163
|
export interface GetUITreeResponse {
|
|
@@ -142,6 +168,9 @@ export interface GetUITreeResponse {
|
|
|
142
168
|
height: number;
|
|
143
169
|
};
|
|
144
170
|
elements: UIElement[];
|
|
171
|
+
snapshot_revision: number;
|
|
172
|
+
captured_at_ms: number;
|
|
173
|
+
loading_state?: LoadingState | null;
|
|
145
174
|
error?: string;
|
|
146
175
|
}
|
|
147
176
|
|
|
@@ -163,12 +192,15 @@ export interface SnapshotSemanticResponse {
|
|
|
163
192
|
|
|
164
193
|
export interface CaptureDebugSnapshotRawResponse {
|
|
165
194
|
timestamp: number;
|
|
195
|
+
snapshot_revision: number;
|
|
196
|
+
captured_at_ms: number;
|
|
166
197
|
reason: string;
|
|
167
198
|
activity: string | null;
|
|
168
199
|
fingerprint: string | null;
|
|
169
200
|
screenshot: string | null;
|
|
170
|
-
ui_tree:
|
|
201
|
+
ui_tree: GetUITreeResponse | null;
|
|
171
202
|
logs: StructuredLogEntry[];
|
|
203
|
+
loading_state?: LoadingState | null;
|
|
172
204
|
device?: DeviceInfo;
|
|
173
205
|
screenshot_error?: string;
|
|
174
206
|
activity_error?: string;
|
|
@@ -215,6 +247,11 @@ export interface ActionTargetResolved {
|
|
|
215
247
|
bounds: [number, number, number, number] | null;
|
|
216
248
|
index: number | null;
|
|
217
249
|
state?: UIElementState | null;
|
|
250
|
+
stable_id?: string | null;
|
|
251
|
+
role?: string | null;
|
|
252
|
+
test_tag?: string | null;
|
|
253
|
+
selector?: UIResolutionSelector | null;
|
|
254
|
+
semantic?: UIElementSemanticMetadata | null;
|
|
218
255
|
}
|
|
219
256
|
|
|
220
257
|
export interface ActionExecutionResult {
|
|
@@ -301,6 +338,17 @@ export interface ExpectStateResponse {
|
|
|
301
338
|
retryable?: boolean;
|
|
302
339
|
}
|
|
303
340
|
|
|
341
|
+
export interface WaitForUIChangeResponse {
|
|
342
|
+
success: boolean;
|
|
343
|
+
observed_change: 'hierarchy_diff' | 'text_change' | 'state_change' | null;
|
|
344
|
+
snapshot_revision?: number;
|
|
345
|
+
timeout: boolean;
|
|
346
|
+
elapsed_ms: number;
|
|
347
|
+
expected_change?: 'hierarchy_diff' | 'text_change' | 'state_change';
|
|
348
|
+
reason?: string;
|
|
349
|
+
loading_state?: LoadingState | null;
|
|
350
|
+
}
|
|
351
|
+
|
|
304
352
|
export interface SwipeResponse {
|
|
305
353
|
device: DeviceInfo;
|
|
306
354
|
success: boolean;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DeviceInfo, UIElement, UIElementState } from "../../types.js"
|
|
1
|
+
import { DeviceInfo, UIElement, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } 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'
|
|
@@ -336,6 +336,52 @@ function parseNumberAttr(value: unknown): number | null {
|
|
|
336
336
|
return Number.isFinite(parsed) ? parsed : null
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
function normalizeClassName(value: unknown): string {
|
|
340
|
+
return typeof value === 'string' ? value.trim().toLowerCase() : ''
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function inferAndroidRole(className: string): string | null {
|
|
344
|
+
if (/seekbar|slider|progress/.test(className)) return 'slider'
|
|
345
|
+
if (/switch|toggle/.test(className)) return 'switch'
|
|
346
|
+
if (/checkbox/.test(className)) return 'checkbox'
|
|
347
|
+
if (/radiobutton|radio/.test(className)) return 'radio'
|
|
348
|
+
if (/edittext|textfield|search/.test(className)) return 'text_field'
|
|
349
|
+
if (/button|fab/.test(className)) return 'button'
|
|
350
|
+
if (/imageview|icon/.test(className)) return 'image'
|
|
351
|
+
if (/recyclerview|scroll|layout|viewgroup|frame/.test(className)) return 'container'
|
|
352
|
+
return null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function buildAndroidSelectorConfidence(source: 'resource_id' | 'content_desc' | 'text' | 'class' | 'none'): SelectorConfidence | null {
|
|
356
|
+
switch (source) {
|
|
357
|
+
case 'resource_id':
|
|
358
|
+
return { score: 1, reason: 'resource_id' }
|
|
359
|
+
case 'content_desc':
|
|
360
|
+
return { score: 0.9, reason: 'content_description' }
|
|
361
|
+
case 'text':
|
|
362
|
+
return { score: 0.6, reason: 'text_match' }
|
|
363
|
+
case 'class':
|
|
364
|
+
return { score: 0.35, reason: 'class_match' }
|
|
365
|
+
default:
|
|
366
|
+
return null
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function buildAndroidSelector(text: string | null, contentDescription: string | null, resourceId: string | null, className: string): UIResolutionSelector | null {
|
|
371
|
+
if (resourceId) return { value: resourceId, confidence: buildAndroidSelectorConfidence('resource_id') }
|
|
372
|
+
if (contentDescription) return { value: contentDescription, confidence: buildAndroidSelectorConfidence('content_desc') }
|
|
373
|
+
if (text) return { value: text, confidence: buildAndroidSelectorConfidence('text') }
|
|
374
|
+
if (className) return { value: className, confidence: buildAndroidSelectorConfidence('class') }
|
|
375
|
+
return null
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function buildAndroidSemantic(clickable: boolean, className: string): UIElementSemanticMetadata {
|
|
379
|
+
return {
|
|
380
|
+
is_clickable: clickable,
|
|
381
|
+
is_container: /recyclerview|scroll|layout|viewgroup|frame/.test(className)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
339
385
|
function isSliderLikeAndroid(node: any): boolean {
|
|
340
386
|
const className = String(node['@_class'] || '').toLowerCase()
|
|
341
387
|
return /seekbar|slider|range|progress/i.test(className)
|
|
@@ -401,29 +447,41 @@ export function traverseNode(node: any, elements: UIElement[], parentIndex: numb
|
|
|
401
447
|
|
|
402
448
|
let currentIndex = -1;
|
|
403
449
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
450
|
+
if (node['@_class']) {
|
|
451
|
+
const text = node['@_text'] || null;
|
|
452
|
+
const contentDescription = node['@_content-desc'] || null;
|
|
453
|
+
const clickable = node['@_clickable'] === 'true';
|
|
454
|
+
const className = String(node['@_class'] || 'unknown');
|
|
455
|
+
const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
|
|
456
|
+
const state = extractAndroidState(node);
|
|
457
|
+
const role = inferAndroidRole(normalizeClassName(className));
|
|
458
|
+
const resourceId = typeof node['@_resource-id'] === 'string' && node['@_resource-id'].trim().length > 0 ? node['@_resource-id'] : null
|
|
459
|
+
const stableId = resourceId ?? (typeof contentDescription === 'string' && contentDescription.trim().length > 0 ? contentDescription : null)
|
|
460
|
+
const testTag = stableId
|
|
461
|
+
const selector = buildAndroidSelector(text, contentDescription, resourceId, normalizeClassName(className))
|
|
462
|
+
const semantic = buildAndroidSemantic(clickable, normalizeClassName(className))
|
|
463
|
+
|
|
464
|
+
const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
|
|
465
|
+
|
|
466
|
+
if (isUseful) {
|
|
467
|
+
const element: UIElement = {
|
|
415
468
|
text,
|
|
416
469
|
contentDescription,
|
|
417
|
-
type:
|
|
418
|
-
resourceId
|
|
470
|
+
type: className,
|
|
471
|
+
resourceId,
|
|
419
472
|
clickable,
|
|
420
473
|
enabled: node['@_enabled'] === 'true',
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
474
|
+
visible: true,
|
|
475
|
+
bounds,
|
|
476
|
+
center: getCenter(bounds),
|
|
477
|
+
depth,
|
|
478
|
+
state,
|
|
479
|
+
stable_id: stableId,
|
|
480
|
+
role,
|
|
481
|
+
test_tag: testTag,
|
|
482
|
+
selector,
|
|
483
|
+
semantic
|
|
484
|
+
};
|
|
427
485
|
|
|
428
486
|
if (parentIndex !== -1) {
|
|
429
487
|
element.parentId = parentIndex;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
3
|
+
import { ToolsObserve } from '../../../src/observe/index.js'
|
|
4
|
+
|
|
5
|
+
async function run() {
|
|
6
|
+
const originalGetUITreeHandler = (ToolsObserve as any).getUITreeHandler
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
let calls = 0
|
|
10
|
+
;(ToolsObserve as any).getUITreeHandler = async () => {
|
|
11
|
+
calls++
|
|
12
|
+
if (calls === 1) {
|
|
13
|
+
return {
|
|
14
|
+
device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
|
|
15
|
+
screen: 'Loading',
|
|
16
|
+
resolution: { width: 1080, height: 2400 },
|
|
17
|
+
elements: [{ text: 'Loading', type: 'TextView', bounds: [0, 0, 100, 40], visible: true }],
|
|
18
|
+
snapshot_revision: 1,
|
|
19
|
+
captured_at_ms: 1000
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
|
|
25
|
+
screen: 'Loaded',
|
|
26
|
+
resolution: { width: 1080, height: 2400 },
|
|
27
|
+
elements: [{ text: 'Loaded', type: 'TextView', bounds: [0, 0, 100, 40], visible: true }],
|
|
28
|
+
snapshot_revision: 2,
|
|
29
|
+
captured_at_ms: 2000
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const success = await ToolsInteract.waitForUIChangeHandler({
|
|
34
|
+
platform: 'android',
|
|
35
|
+
deviceId: 'mock',
|
|
36
|
+
expected_change: 'text_change',
|
|
37
|
+
timeout_ms: 1500,
|
|
38
|
+
stability_window_ms: 1
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
assert.strictEqual(success.success, true)
|
|
42
|
+
assert.strictEqual(success.observed_change, 'text_change')
|
|
43
|
+
assert.strictEqual(success.snapshot_revision, 2)
|
|
44
|
+
assert.strictEqual(success.timeout, false)
|
|
45
|
+
|
|
46
|
+
;(ToolsObserve as any).getUITreeHandler = async () => ({
|
|
47
|
+
device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
|
|
48
|
+
screen: 'Static',
|
|
49
|
+
resolution: { width: 1080, height: 2400 },
|
|
50
|
+
elements: [{ text: 'Static', type: 'TextView', bounds: [0, 0, 100, 40], visible: true }],
|
|
51
|
+
snapshot_revision: 9,
|
|
52
|
+
captured_at_ms: 3000
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const timeout = await ToolsInteract.waitForUIChangeHandler({
|
|
56
|
+
platform: 'android',
|
|
57
|
+
deviceId: 'mock',
|
|
58
|
+
expected_change: 'state_change',
|
|
59
|
+
timeout_ms: 700,
|
|
60
|
+
stability_window_ms: 1
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
assert.strictEqual(timeout.success, false)
|
|
64
|
+
assert.strictEqual(timeout.observed_change, null)
|
|
65
|
+
assert.strictEqual(timeout.timeout, true)
|
|
66
|
+
|
|
67
|
+
console.log('wait_for_ui_change tests passed')
|
|
68
|
+
} finally {
|
|
69
|
+
;(ToolsObserve as any).getUITreeHandler = originalGetUITreeHandler
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
run().catch((error) => {
|
|
74
|
+
console.error(error)
|
|
75
|
+
process.exit(1)
|
|
76
|
+
})
|
|
@@ -8,6 +8,7 @@ async function run() {
|
|
|
8
8
|
'@_class': 'android.widget.SeekBar',
|
|
9
9
|
'@_text': '',
|
|
10
10
|
'@_content-desc': 'Duration',
|
|
11
|
+
'@_resource-id': 'com.example:id/duration',
|
|
11
12
|
'@_clickable': 'true',
|
|
12
13
|
'@_enabled': 'true',
|
|
13
14
|
'@_selected': 'true',
|
|
@@ -21,18 +22,64 @@ async function run() {
|
|
|
21
22
|
assert.strictEqual(androidElements[0].state?.raw_value, 7)
|
|
22
23
|
assert.strictEqual(androidElements[0].state?.value, 50)
|
|
23
24
|
assert.deepStrictEqual(androidElements[0].state?.value_range, { min: 0, max: 14 })
|
|
25
|
+
assert.strictEqual(androidElements[0].stable_id, 'com.example:id/duration')
|
|
26
|
+
assert.strictEqual(androidElements[0].role, 'slider')
|
|
27
|
+
assert.strictEqual(androidElements[0].test_tag, 'com.example:id/duration')
|
|
28
|
+
assert.deepStrictEqual(androidElements[0].selector, {
|
|
29
|
+
value: 'com.example:id/duration',
|
|
30
|
+
confidence: { score: 1, reason: 'resource_id' }
|
|
31
|
+
})
|
|
32
|
+
assert.deepStrictEqual(androidElements[0].semantic, { is_clickable: true, is_container: false })
|
|
33
|
+
|
|
34
|
+
const androidFallbackElements: any[] = []
|
|
35
|
+
traverseNode({
|
|
36
|
+
'@_class': 'android.widget.Button',
|
|
37
|
+
'@_text': '',
|
|
38
|
+
'@_content-desc': 'Save',
|
|
39
|
+
'@_clickable': 'true',
|
|
40
|
+
'@_enabled': 'true',
|
|
41
|
+
'@_bounds': '[0,0][100,50]'
|
|
42
|
+
}, androidFallbackElements)
|
|
43
|
+
|
|
44
|
+
assert.strictEqual(androidFallbackElements.length, 1)
|
|
45
|
+
assert.strictEqual(androidFallbackElements[0].resourceId, null)
|
|
46
|
+
assert.strictEqual(androidFallbackElements[0].stable_id, 'Save')
|
|
47
|
+
assert.deepStrictEqual(androidFallbackElements[0].selector, {
|
|
48
|
+
value: 'Save',
|
|
49
|
+
confidence: { score: 0.9, reason: 'content_description' }
|
|
50
|
+
})
|
|
24
51
|
|
|
25
52
|
const iosElements: any[] = []
|
|
26
53
|
traverseIDBNode({
|
|
27
54
|
AXElementType: 'Slider',
|
|
28
55
|
AXLabel: 'Playback speed',
|
|
29
56
|
AXValue: '0.75',
|
|
57
|
+
AXUniqueId: 'playback_speed_slider',
|
|
30
58
|
AXTraits: ['UIAccessibilityTraitAdjustable']
|
|
31
59
|
}, iosElements)
|
|
32
60
|
|
|
33
61
|
assert.strictEqual(iosElements.length, 1)
|
|
34
62
|
assert.strictEqual(iosElements[0].state?.value, 75)
|
|
35
63
|
assert.strictEqual(iosElements[0].state?.raw_value, 0.75)
|
|
64
|
+
assert.strictEqual(iosElements[0].stable_id, 'playback_speed_slider')
|
|
65
|
+
assert.strictEqual(iosElements[0].role, 'slider')
|
|
66
|
+
assert.strictEqual(iosElements[0].test_tag, 'playback_speed_slider')
|
|
67
|
+
assert.deepStrictEqual(iosElements[0].selector, {
|
|
68
|
+
value: 'playback_speed_slider',
|
|
69
|
+
confidence: { score: 1, reason: 'accessibility_identifier' }
|
|
70
|
+
})
|
|
71
|
+
assert.deepStrictEqual(iosElements[0].semantic, { is_clickable: true, is_container: false })
|
|
72
|
+
|
|
73
|
+
const iosFallbackElements: any[] = []
|
|
74
|
+
traverseIDBNode({
|
|
75
|
+
AXElementType: 'Button',
|
|
76
|
+
AXLabel: 'Save',
|
|
77
|
+
AXTraits: ['UIAccessibilityTraitButton'],
|
|
78
|
+
AXUniqueId: 'fallback_unique_id'
|
|
79
|
+
}, iosFallbackElements)
|
|
80
|
+
|
|
81
|
+
assert.strictEqual(iosFallbackElements.length, 1)
|
|
82
|
+
assert.strictEqual(iosFallbackElements[0].stable_id, 'fallback_unique_id')
|
|
36
83
|
|
|
37
84
|
console.log('state extraction tests passed')
|
|
38
85
|
}
|
|
@@ -8,6 +8,7 @@ import { ToolsObserve } from '../../../src/observe/index.js'
|
|
|
8
8
|
async function run() {
|
|
9
9
|
const originalInstallAppHandler = (ToolsManage as any).installAppHandler
|
|
10
10
|
const originalWaitForUIHandler = (ToolsInteract as any).waitForUIHandler
|
|
11
|
+
const originalWaitForUIChangeHandler = (ToolsInteract as any).waitForUIChangeHandler
|
|
11
12
|
const originalTapElementHandler = (ToolsInteract as any).tapElementHandler
|
|
12
13
|
const originalTapHandler = (ToolsInteract as any).tapHandler
|
|
13
14
|
const originalExpectScreenHandler = (ToolsInteract as any).expectScreenHandler
|
|
@@ -145,12 +146,16 @@ async function run() {
|
|
|
145
146
|
;(ToolsObserve as any).getUITreeHandler = async () => ({
|
|
146
147
|
device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
|
|
147
148
|
resolution: { width: 1080, height: 2400 },
|
|
149
|
+
screen: 'Notifications',
|
|
148
150
|
elements: [{
|
|
149
151
|
text: 'Notifications',
|
|
150
152
|
depth: 0,
|
|
151
153
|
center: { x: 50, y: 20 },
|
|
152
154
|
state: { checked: true, selected: 'Notifications' }
|
|
153
|
-
}]
|
|
155
|
+
}],
|
|
156
|
+
snapshot_revision: 12,
|
|
157
|
+
captured_at_ms: 1710000000123,
|
|
158
|
+
loading_state: { active: true, signal: 'progress_indicator', source: 'ui_tree' }
|
|
154
159
|
})
|
|
155
160
|
|
|
156
161
|
;(ToolsInteract as any).expectStateHandler = async () => ({
|
|
@@ -227,8 +232,12 @@ async function run() {
|
|
|
227
232
|
|
|
228
233
|
;(ToolsObserve as any).getUITreeHandler = async () => ({
|
|
229
234
|
device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
|
|
235
|
+
screen: 'Login',
|
|
230
236
|
resolution: { width: 1080, height: 2400 },
|
|
231
|
-
elements: [{ text: 'Login', depth: 0, center: { x: 50, y: 20 } }]
|
|
237
|
+
elements: [{ text: 'Login', depth: 0, center: { x: 50, y: 20 } }],
|
|
238
|
+
snapshot_revision: 12,
|
|
239
|
+
captured_at_ms: 1710000000123,
|
|
240
|
+
loading_state: { active: true, signal: 'progress_indicator', source: 'ui_tree' }
|
|
232
241
|
})
|
|
233
242
|
|
|
234
243
|
const uiTreeResponse = await handleToolCall('get_ui_tree', { platform: 'android' })
|
|
@@ -236,16 +245,21 @@ async function run() {
|
|
|
236
245
|
assert.strictEqual(uiTreePayload.elements.length, 1)
|
|
237
246
|
assert.strictEqual(uiTreePayload.resolution.height, 2400)
|
|
238
247
|
assert.strictEqual(uiTreePayload.elements[0].text, 'Login')
|
|
248
|
+
assert.strictEqual(uiTreePayload.snapshot_revision, 12)
|
|
249
|
+
assert.strictEqual(uiTreePayload.loading_state.signal, 'progress_indicator')
|
|
239
250
|
|
|
240
251
|
;(ToolsObserve as any).captureDebugSnapshotHandler = async () => ({
|
|
241
252
|
raw: {
|
|
242
253
|
timestamp: 1710000000000,
|
|
254
|
+
snapshot_revision: 12,
|
|
255
|
+
captured_at_ms: 1710000000123,
|
|
243
256
|
reason: 'manual',
|
|
244
257
|
activity: 'com.example.MainActivity',
|
|
245
258
|
fingerprint: 'fp_raw',
|
|
246
259
|
screenshot: 'base64',
|
|
247
|
-
ui_tree: { screen: 'Home', elements: [] },
|
|
260
|
+
ui_tree: { screen: 'Home', elements: [], snapshot_revision: 12, captured_at_ms: 1710000000123, loading_state: { active: true, signal: 'spinner', source: 'snapshot' } },
|
|
248
261
|
logs: [],
|
|
262
|
+
loading_state: { active: true, signal: 'spinner', source: 'snapshot' },
|
|
249
263
|
device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true }
|
|
250
264
|
},
|
|
251
265
|
semantic: {
|
|
@@ -260,13 +274,33 @@ async function run() {
|
|
|
260
274
|
const snapshotResponse = await handleToolCall('capture_debug_snapshot', { platform: 'android' })
|
|
261
275
|
const snapshotPayload = JSON.parse((snapshotResponse as any).content[0].text)
|
|
262
276
|
assert.strictEqual(snapshotPayload.raw.fingerprint, 'fp_raw')
|
|
277
|
+
assert.strictEqual(snapshotPayload.raw.snapshot_revision, 12)
|
|
278
|
+
assert.strictEqual(snapshotPayload.raw.loading_state.signal, 'spinner')
|
|
263
279
|
assert.strictEqual(snapshotPayload.semantic.screen, 'Home')
|
|
264
280
|
assert.strictEqual(snapshotPayload.semantic.confidence, 0.8)
|
|
265
281
|
|
|
282
|
+
;(ToolsInteract as any).waitForUIChangeHandler = async () => ({
|
|
283
|
+
success: true,
|
|
284
|
+
observed_change: 'text_change',
|
|
285
|
+
snapshot_revision: 13,
|
|
286
|
+
timeout: false,
|
|
287
|
+
elapsed_ms: 1550,
|
|
288
|
+
expected_change: 'text_change',
|
|
289
|
+
loading_state: { active: false, signal: 'spinner', source: 'ui_tree' },
|
|
290
|
+
reason: 'UI change observed'
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const waitForUIChangeResponse = await handleToolCall('wait_for_ui_change', { expected_change: 'text_change' })
|
|
294
|
+
const waitForUIChangePayload = JSON.parse((waitForUIChangeResponse as any).content[0].text)
|
|
295
|
+
assert.strictEqual(waitForUIChangePayload.success, true)
|
|
296
|
+
assert.strictEqual(waitForUIChangePayload.observed_change, 'text_change')
|
|
297
|
+
assert.strictEqual(waitForUIChangePayload.snapshot_revision, 13)
|
|
298
|
+
|
|
266
299
|
console.log('server response-shape tests passed')
|
|
267
300
|
} finally {
|
|
268
301
|
;(ToolsManage as any).installAppHandler = originalInstallAppHandler
|
|
269
302
|
;(ToolsInteract as any).waitForUIHandler = originalWaitForUIHandler
|
|
303
|
+
;(ToolsInteract as any).waitForUIChangeHandler = originalWaitForUIChangeHandler
|
|
270
304
|
;(ToolsInteract as any).tapElementHandler = originalTapElementHandler
|
|
271
305
|
;(ToolsInteract as any).tapHandler = originalTapHandler
|
|
272
306
|
;(ToolsInteract as any).expectScreenHandler = originalExpectScreenHandler
|