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.
@@ -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
  }
@@ -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 = { timestamp, reason: reason || '', activity: null, fingerprint: null, screenshot: null, ui_tree: null, logs: [] }
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
  }
@@ -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
- if (attempts === maxAttempts) {
544
- return {
545
- device,
546
- screen: "",
547
- resolution: { width: 0, height: 0 },
548
- elements: [],
549
- error: `Failed to retrieve valid UI dump after ${maxAttempts} attempts.`
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
- return {
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,
@@ -13,7 +13,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
13
13
 
14
14
  export const serverInfo = {
15
15
  name: 'mobile-debug-mcp',
16
- version: '0.25.1'
16
+ version: '0.26.0'
17
17
  }
18
18
 
19
19
  export function createServer() {
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