mobile-debug-mcp 0.25.1 → 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 +113 -0
- package/dist/observe/android.js +10 -1
- package/dist/observe/index.js +19 -1
- package/dist/observe/ios.js +15 -1
- 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/docs/CHANGELOG.md +6 -0
- package/docs/rfcs/003-wait-and-synchronization-reliability.md +296 -0
- package/docs/specs/mcp-tooling-spec-v1.md +8 -0
- package/docs/tools/interact.md +21 -0
- package/docs/tools/observe.md +4 -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 +151 -0
- package/src/observe/android.ts +11 -1
- package/src/observe/index.ts +26 -1
- package/src/observe/ios.ts +28 -13
- 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 +23 -0
- package/test/unit/interact/wait_for_ui_change.test.ts +76 -0
- package/test/unit/server/response_shapes.test.ts +37 -3
- package/docs/rfcs/003-wait-and-synchronization-reliability +0 -232
package/src/observe/android.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { promises as fsPromises } from "fs"
|
|
|
7
7
|
import path from "path"
|
|
8
8
|
import { computeScreenFingerprint } from "../utils/ui/index.js"
|
|
9
9
|
import { parsePngSize } from "../utils/image.js"
|
|
10
|
+
import { deriveSnapshotMetadata } from "./snapshot-metadata.js"
|
|
10
11
|
|
|
11
12
|
const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
|
|
12
13
|
|
|
@@ -74,20 +75,29 @@ export class AndroidObserve {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
const snapshotMetadata = deriveSnapshotMetadata(`android:${deviceInfo.id}`, {
|
|
79
|
+
screen: "",
|
|
80
|
+
resolution,
|
|
81
|
+
elements
|
|
82
|
+
}, 'ui_tree')
|
|
83
|
+
|
|
77
84
|
return {
|
|
78
85
|
device: deviceInfo,
|
|
79
86
|
screen: "",
|
|
80
87
|
resolution,
|
|
81
|
-
elements
|
|
88
|
+
elements,
|
|
89
|
+
...snapshotMetadata
|
|
82
90
|
};
|
|
83
91
|
} catch (e) {
|
|
84
92
|
const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
85
93
|
console.error(errorMessage);
|
|
94
|
+
const snapshotMetadata = deriveSnapshotMetadata(`android:${deviceInfo.id}`, null, 'ui_tree')
|
|
86
95
|
return {
|
|
87
96
|
device: deviceInfo,
|
|
88
97
|
screen: "",
|
|
89
98
|
resolution: { width: 0, height: 0 },
|
|
90
99
|
elements: [],
|
|
100
|
+
...snapshotMetadata,
|
|
91
101
|
error: errorMessage
|
|
92
102
|
};
|
|
93
103
|
}
|
package/src/observe/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
CaptureDebugSnapshotRawResponse,
|
|
6
6
|
SnapshotSemanticResponse
|
|
7
7
|
} from '../types.js'
|
|
8
|
+
import { deriveSnapshotMetadata } from './snapshot-metadata.js'
|
|
8
9
|
|
|
9
10
|
export { AndroidObserve } from './android.js'
|
|
10
11
|
export { iOSObserve } from './ios.js'
|
|
@@ -245,7 +246,17 @@ export class ToolsObserve {
|
|
|
245
246
|
|
|
246
247
|
static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
|
|
247
248
|
const timestamp = Date.now()
|
|
248
|
-
const raw: CaptureDebugSnapshotRawResponse = {
|
|
249
|
+
const raw: CaptureDebugSnapshotRawResponse = {
|
|
250
|
+
timestamp,
|
|
251
|
+
snapshot_revision: 0,
|
|
252
|
+
captured_at_ms: timestamp,
|
|
253
|
+
reason: reason || '',
|
|
254
|
+
activity: null,
|
|
255
|
+
fingerprint: null,
|
|
256
|
+
screenshot: null,
|
|
257
|
+
ui_tree: null,
|
|
258
|
+
logs: []
|
|
259
|
+
}
|
|
249
260
|
|
|
250
261
|
// Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
|
|
251
262
|
const sid = sessionId || 'default'
|
|
@@ -335,6 +346,20 @@ export class ToolsObserve {
|
|
|
335
346
|
}
|
|
336
347
|
}
|
|
337
348
|
|
|
349
|
+
const snapshotDeviceKey = raw.ui_tree?.device
|
|
350
|
+
? `${raw.ui_tree.device.platform}:${raw.ui_tree.device.id}`
|
|
351
|
+
: `${platform || 'unknown'}:${deviceId || 'default'}`
|
|
352
|
+
const snapshotMetadata = deriveSnapshotMetadata(
|
|
353
|
+
snapshotDeviceKey,
|
|
354
|
+
raw.ui_tree,
|
|
355
|
+
'snapshot',
|
|
356
|
+
raw.ui_tree?.snapshot_revision ? null : (raw.fingerprint || raw.activity || null)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
raw.snapshot_revision = raw.ui_tree?.snapshot_revision ?? snapshotMetadata.snapshot_revision
|
|
360
|
+
raw.captured_at_ms = raw.ui_tree?.captured_at_ms ?? snapshotMetadata.captured_at_ms
|
|
361
|
+
raw.loading_state = raw.ui_tree?.loading_state ?? snapshotMetadata.loading_state
|
|
362
|
+
|
|
338
363
|
const semantic = deriveSnapshotSemantic(raw)
|
|
339
364
|
return semantic ? { raw, semantic } : { raw }
|
|
340
365
|
}
|
package/src/observe/ios.ts
CHANGED
|
@@ -7,6 +7,7 @@ import path from 'path'
|
|
|
7
7
|
import { parseLogLine } from '../utils/android/utils.js'
|
|
8
8
|
import { computeScreenFingerprint } from '../utils/ui/index.js'
|
|
9
9
|
import { parsePngSize } from '../utils/image.js'
|
|
10
|
+
import { deriveSnapshotMetadata } from './snapshot-metadata.js'
|
|
10
11
|
|
|
11
12
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
12
13
|
let iosExecCommand = execCommand
|
|
@@ -485,16 +486,19 @@ export class iOSObserve {
|
|
|
485
486
|
|
|
486
487
|
async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
|
|
487
488
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
489
|
+
const deviceKey = `ios:${device.id}`
|
|
488
490
|
|
|
489
491
|
const idbExists = await isIDBInstalled();
|
|
490
492
|
if (!idbExists) {
|
|
493
|
+
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
|
|
491
494
|
return {
|
|
492
495
|
device,
|
|
493
496
|
screen: "",
|
|
494
497
|
resolution: { width: 0, height: 0 },
|
|
495
498
|
elements: [],
|
|
499
|
+
...snapshotMetadata,
|
|
496
500
|
error: "iOS UI tree retrieval requires 'idb' (iOS Device Bridge). Please install it via Homebrew: `brew tap facebook/fb && brew install idb-companion` and `pip3 install fb-idb`."
|
|
497
|
-
|
|
501
|
+
};
|
|
498
502
|
}
|
|
499
503
|
|
|
500
504
|
const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
|
|
@@ -540,15 +544,17 @@ export class iOSObserve {
|
|
|
540
544
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
541
545
|
}
|
|
542
546
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
547
|
+
if (attempts === maxAttempts) {
|
|
548
|
+
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
|
|
549
|
+
return {
|
|
550
|
+
device,
|
|
551
|
+
screen: "",
|
|
552
|
+
resolution: { width: 0, height: 0 },
|
|
553
|
+
elements: [],
|
|
554
|
+
...snapshotMetadata,
|
|
555
|
+
error: `Failed to retrieve valid UI dump after ${maxAttempts} attempts.`
|
|
556
|
+
};
|
|
557
|
+
}
|
|
552
558
|
}
|
|
553
559
|
|
|
554
560
|
try {
|
|
@@ -569,20 +575,29 @@ export class iOSObserve {
|
|
|
569
575
|
height = rootBounds[3] - rootBounds[1];
|
|
570
576
|
}
|
|
571
577
|
|
|
578
|
+
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, {
|
|
579
|
+
screen: "",
|
|
580
|
+
resolution: { width, height },
|
|
581
|
+
elements
|
|
582
|
+
}, 'ui_tree')
|
|
583
|
+
|
|
572
584
|
return {
|
|
573
585
|
device,
|
|
574
586
|
screen: "",
|
|
575
587
|
resolution: { width, height },
|
|
576
|
-
elements
|
|
588
|
+
elements,
|
|
589
|
+
...snapshotMetadata
|
|
577
590
|
};
|
|
578
591
|
} catch (e) {
|
|
579
|
-
|
|
592
|
+
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
|
|
593
|
+
return {
|
|
580
594
|
device,
|
|
581
595
|
screen: "",
|
|
582
596
|
resolution: { width: 0, height: 0 },
|
|
583
597
|
elements: [],
|
|
598
|
+
...snapshotMetadata,
|
|
584
599
|
error: `Failed to parse idb output: ${e instanceof Error ? e.message : String(e)}`
|
|
585
|
-
|
|
600
|
+
};
|
|
586
601
|
}
|
|
587
602
|
}
|
|
588
603
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import type { GetUITreeResponse, LoadingState, UIElement } from '../types.js'
|
|
3
|
+
|
|
4
|
+
interface SnapshotState {
|
|
5
|
+
revision: number
|
|
6
|
+
signature: string | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const snapshotStateByDevice = new Map<string, SnapshotState>()
|
|
10
|
+
|
|
11
|
+
function normalize(value: unknown): string {
|
|
12
|
+
if (value === null || value === undefined) return ''
|
|
13
|
+
return String(value).trim().toLowerCase()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeBounds(bounds: unknown): [number, number, number, number] | null {
|
|
17
|
+
if (!Array.isArray(bounds) || bounds.length < 4) return null
|
|
18
|
+
const normalized = bounds.slice(0, 4).map((value) => Number(value))
|
|
19
|
+
if (normalized.some((value) => Number.isNaN(value))) return null
|
|
20
|
+
return normalized as [number, number, number, number]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stableElementSignature(element: UIElement) {
|
|
24
|
+
return {
|
|
25
|
+
text: normalize(element.text),
|
|
26
|
+
contentDescription: normalize(element.contentDescription),
|
|
27
|
+
resourceId: normalize(element.resourceId),
|
|
28
|
+
type: normalize(element.type),
|
|
29
|
+
stable_id: normalize(element.stable_id),
|
|
30
|
+
role: normalize(element.role),
|
|
31
|
+
test_tag: normalize(element.test_tag),
|
|
32
|
+
selector: normalize(element.selector?.value),
|
|
33
|
+
clickable: !!element.clickable,
|
|
34
|
+
enabled: !!element.enabled,
|
|
35
|
+
visible: !!element.visible,
|
|
36
|
+
state: element.state ?? null,
|
|
37
|
+
bounds: normalizeBounds(element.bounds)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function computeSnapshotSignature(tree: Pick<GetUITreeResponse, 'elements' | 'screen' | 'resolution' | 'error'> | null | undefined): string | null {
|
|
42
|
+
if (!tree || tree.error) return null
|
|
43
|
+
|
|
44
|
+
const payload = {
|
|
45
|
+
screen: normalize(tree.screen),
|
|
46
|
+
resolution: tree.resolution || { width: 0, height: 0 },
|
|
47
|
+
elements: Array.isArray(tree.elements) ? tree.elements.map((element) => stableElementSignature(element)) : []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function detectLoadingState(tree: Pick<GetUITreeResponse, 'elements' | 'error'> | null | undefined, source: string): LoadingState | null {
|
|
54
|
+
if (!tree || tree.error || !Array.isArray(tree.elements)) return null
|
|
55
|
+
|
|
56
|
+
for (const element of tree.elements) {
|
|
57
|
+
if (!element?.visible) continue
|
|
58
|
+
const text = normalize(element?.text ?? element?.contentDescription ?? '')
|
|
59
|
+
const type = normalize(element?.type ?? '')
|
|
60
|
+
const combined = `${type} ${text}`
|
|
61
|
+
if (/progress|spinner|loading|please wait|busy|loading indicator|skeleton|pending/.test(combined)) {
|
|
62
|
+
const signal = /progress/.test(combined)
|
|
63
|
+
? 'progress_indicator'
|
|
64
|
+
: /spinner/.test(combined)
|
|
65
|
+
? 'spinner'
|
|
66
|
+
: /busy/.test(combined)
|
|
67
|
+
? 'busy_indicator'
|
|
68
|
+
: /skeleton/.test(combined)
|
|
69
|
+
? 'skeleton'
|
|
70
|
+
: 'loading_indicator'
|
|
71
|
+
return { active: true, signal, source }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function deriveSnapshotMetadata(
|
|
79
|
+
deviceKey: string,
|
|
80
|
+
tree: Pick<GetUITreeResponse, 'elements' | 'screen' | 'resolution' | 'error'> | null | undefined,
|
|
81
|
+
source: string,
|
|
82
|
+
signatureOverride?: string | null
|
|
83
|
+
) {
|
|
84
|
+
const signature = signatureOverride ?? computeSnapshotSignature(tree)
|
|
85
|
+
const previous = snapshotStateByDevice.get(deviceKey)
|
|
86
|
+
|
|
87
|
+
let revision = 1
|
|
88
|
+
if (previous) {
|
|
89
|
+
if (signature === null) {
|
|
90
|
+
revision = previous.revision
|
|
91
|
+
} else {
|
|
92
|
+
revision = previous.signature === signature ? previous.revision : previous.revision + 1
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
snapshotStateByDevice.set(deviceKey, { revision, signature })
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
snapshot_revision: revision,
|
|
100
|
+
captured_at_ms: Date.now(),
|
|
101
|
+
loading_state: detectLoadingState(tree, source)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function resetSnapshotMetadataForTests() {
|
|
106
|
+
snapshotStateByDevice.clear()
|
|
107
|
+
}
|
|
@@ -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
|
@@ -109,6 +109,12 @@ export interface UIElementSemanticMetadata {
|
|
|
109
109
|
is_container: boolean;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export interface LoadingState {
|
|
113
|
+
active: boolean;
|
|
114
|
+
signal: string;
|
|
115
|
+
source: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
112
118
|
export interface CaptureAndroidScreenResponse {
|
|
113
119
|
device: DeviceInfo;
|
|
114
120
|
screenshot: string; // base64 encoded string
|
|
@@ -162,6 +168,9 @@ export interface GetUITreeResponse {
|
|
|
162
168
|
height: number;
|
|
163
169
|
};
|
|
164
170
|
elements: UIElement[];
|
|
171
|
+
snapshot_revision: number;
|
|
172
|
+
captured_at_ms: number;
|
|
173
|
+
loading_state?: LoadingState | null;
|
|
165
174
|
error?: string;
|
|
166
175
|
}
|
|
167
176
|
|
|
@@ -183,12 +192,15 @@ export interface SnapshotSemanticResponse {
|
|
|
183
192
|
|
|
184
193
|
export interface CaptureDebugSnapshotRawResponse {
|
|
185
194
|
timestamp: number;
|
|
195
|
+
snapshot_revision: number;
|
|
196
|
+
captured_at_ms: number;
|
|
186
197
|
reason: string;
|
|
187
198
|
activity: string | null;
|
|
188
199
|
fingerprint: string | null;
|
|
189
200
|
screenshot: string | null;
|
|
190
201
|
ui_tree: GetUITreeResponse | null;
|
|
191
202
|
logs: StructuredLogEntry[];
|
|
203
|
+
loading_state?: LoadingState | null;
|
|
192
204
|
device?: DeviceInfo;
|
|
193
205
|
screenshot_error?: string;
|
|
194
206
|
activity_error?: string;
|
|
@@ -326,6 +338,17 @@ export interface ExpectStateResponse {
|
|
|
326
338
|
retryable?: boolean;
|
|
327
339
|
}
|
|
328
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
|
+
|
|
329
352
|
export interface SwipeResponse {
|
|
330
353
|
device: DeviceInfo;
|
|
331
354
|
success: boolean;
|
|
@@ -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 @@ 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
|