mobile-debug-mcp 0.24.7 → 0.24.8

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/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
4
4
 
5
5
  > **Support:**
6
- > * Android support
7
- > * iOS only tested on simulator
8
- > * KMP support
9
- > * Flutter iOS projects not fetching logs
10
- > * React native not tested
6
+ > * KMP
7
+ > * Android
8
+ > * iOS
9
+ > * Flutter - not tested
10
+ > * React native - not tested
11
11
 
12
12
  ## Requirements
13
13
 
@@ -7,6 +7,14 @@ import { ToolsObserve } from '../observe/index.js';
7
7
  import { nextActionId } from '../server/common.js';
8
8
  export class ToolsInteract {
9
9
  static _maxResolvedUiElements = 256;
10
+ static _sliderSearchLookahead = 8;
11
+ static _sliderNegativeGapTolerancePx = 32;
12
+ static _sliderPositiveGapLimitPx = 640;
13
+ static _sliderTrackMinLengthPx = 220;
14
+ static _sliderTrackMaxThicknessPx = 180;
15
+ static _sliderTrackLengthRatio = 0.18;
16
+ static _sliderTrackThicknessRatio = 0.08;
17
+ static _sliderLabelWidthRatio = 1.5;
10
18
  static _resolvedUiElements = new Map();
11
19
  static _normalize(s) {
12
20
  if (s === null || s === undefined)
@@ -174,6 +182,63 @@ export class ToolsInteract {
174
182
  }
175
183
  return best;
176
184
  }
185
+ static _resolveNearbyActionableControl(elements, chosen, screen) {
186
+ if (!chosen)
187
+ return null;
188
+ const labelBounds = ToolsInteract._normalizeBounds(chosen.el.bounds);
189
+ if (!labelBounds)
190
+ return null;
191
+ const [labelLeft, labelTop, labelRight, labelBottom] = labelBounds;
192
+ const labelWidth = labelRight - labelLeft;
193
+ const labelHeight = labelBottom - labelTop;
194
+ const screenWidth = Number(screen?.width) > 0 ? Number(screen?.width) : 0;
195
+ const screenHeight = Number(screen?.height) > 0 ? Number(screen?.height) : 0;
196
+ const minTrackLengthPx = Math.max(ToolsInteract._sliderTrackMinLengthPx, screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackLengthRatio) : 0, screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackLengthRatio) : 0);
197
+ const maxTrackThicknessPx = Math.max(ToolsInteract._sliderTrackMaxThicknessPx, screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackThicknessRatio) : 0, screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackThicknessRatio) : 0);
198
+ let best = null;
199
+ let bestScore = Infinity;
200
+ for (let i = chosen.idx + 1; i < Math.min(elements.length, chosen.idx + ToolsInteract._sliderSearchLookahead); i++) {
201
+ const candidate = elements[i];
202
+ if (!candidate || !(candidate.clickable || candidate.focusable) || candidate.visible === false)
203
+ continue;
204
+ const candidateBounds = ToolsInteract._normalizeBounds(candidate.bounds);
205
+ if (!candidateBounds)
206
+ continue;
207
+ const [left, top, right] = candidateBounds;
208
+ const width = right - left;
209
+ const height = candidateBounds[3] - top;
210
+ const verticalGap = top - labelBottom;
211
+ if (verticalGap < -ToolsInteract._sliderNegativeGapTolerancePx || verticalGap > ToolsInteract._sliderPositiveGapLimitPx)
212
+ continue;
213
+ const horizontalOverlap = Math.min(labelRight, right) - Math.max(labelLeft, left);
214
+ if (horizontalOverlap < -ToolsInteract._sliderNegativeGapTolerancePx)
215
+ continue;
216
+ const candidateText = ToolsInteract._normalize(candidate.text ?? candidate.label ?? candidate.value ?? '');
217
+ const candidateContent = ToolsInteract._normalize(candidate.contentDescription ?? candidate.contentDesc ?? candidate.accessibilityLabel ?? '');
218
+ const candidateClass = ToolsInteract._normalize(candidate.type ?? candidate.class ?? '');
219
+ let score = verticalGap;
220
+ const horizontalTrackLike = width >= Math.max(minTrackLengthPx, Math.floor(labelWidth * ToolsInteract._sliderLabelWidthRatio)) &&
221
+ height <= maxTrackThicknessPx;
222
+ const verticalTrackLike = height >= Math.max(minTrackLengthPx, Math.floor(labelHeight * ToolsInteract._sliderLabelWidthRatio)) &&
223
+ width <= maxTrackThicknessPx;
224
+ const trackLike = /slider|seek|range/i.test(candidateClass) || horizontalTrackLike || verticalTrackLike;
225
+ if (!candidateText && !candidateContent)
226
+ score -= 18;
227
+ if (trackLike)
228
+ score -= 30;
229
+ if (/view|layout|group|frame/i.test(candidateClass))
230
+ score -= 10;
231
+ if (width > labelWidth * ToolsInteract._sliderLabelWidthRatio)
232
+ score -= 8;
233
+ if (candidateText || candidateContent)
234
+ score += 20;
235
+ if (score < bestScore) {
236
+ bestScore = score;
237
+ best = { el: candidate, idx: i, sliderLike: trackLike };
238
+ }
239
+ }
240
+ return best;
241
+ }
177
242
  static async getInteractionService(platform, deviceId) {
178
243
  const effectivePlatform = platform || 'android';
179
244
  const resolved = await resolveTargetDevice({ platform: effectivePlatform, deviceId });
@@ -262,6 +327,7 @@ export class ToolsInteract {
262
327
  return { found: false, error: 'Empty query' };
263
328
  let best = null;
264
329
  let bestScore = 0;
330
+ let lastTree = null;
265
331
  const scoreElement = (el) => {
266
332
  if (!el || !el.visible)
267
333
  return 0;
@@ -305,6 +371,7 @@ export class ToolsInteract {
305
371
  while (Date.now() <= deadline) {
306
372
  try {
307
373
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
374
+ lastTree = tree;
308
375
  if (tree && Array.isArray(tree.elements)) {
309
376
  const elements = tree.elements;
310
377
  for (let i = 0; i < elements.length; i++) {
@@ -342,8 +409,8 @@ export class ToolsInteract {
342
409
  return { found: false, error: 'Element not found' };
343
410
  // If the best match is not interactable, try to resolve an actionable ancestor.
344
411
  try {
345
- const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
346
- const elements = (tree && Array.isArray(tree.elements)) ? tree.elements : [];
412
+ const elements = (lastTree && Array.isArray(lastTree.elements)) ? lastTree.elements : [];
413
+ const screen = lastTree?.resolution && typeof lastTree.resolution === 'object' ? lastTree.resolution : null;
347
414
  let chosen = best;
348
415
  const childBounds = Array.isArray(chosen?.bounds) ? chosen.bounds : null;
349
416
  // Strategy 1: if parentId references an index, climb that chain
@@ -412,6 +479,15 @@ export class ToolsInteract {
412
479
  // small score bump to reflect actionability
413
480
  bestScore = Math.min(1, bestScore + 0.02);
414
481
  }
482
+ if (best && !(best.clickable || best.focusable)) {
483
+ const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best, idx: best._index ?? elements.indexOf(best) }, screen);
484
+ if (nearbyActionable) {
485
+ best = nearbyActionable.el;
486
+ best._index = nearbyActionable.idx;
487
+ best._interactable = true;
488
+ best._sliderLike = nearbyActionable.sliderLike;
489
+ }
490
+ }
415
491
  }
416
492
  catch (e) {
417
493
  console.error('Error resolving ancestor:', e);
@@ -431,9 +507,19 @@ export class ToolsInteract {
431
507
  tapCoordinates,
432
508
  telemetry: {
433
509
  matchedIndex: best?._index ?? null,
434
- matchedInteractable: !!best?._interactable
510
+ matchedInteractable: !!best?._interactable,
511
+ sliderLike: !!best?._sliderLike
435
512
  }
436
513
  };
514
+ if (best?._sliderLike) {
515
+ const isVertical = !!boundsObj && (boundsObj.bottom - boundsObj.top) > (boundsObj.right - boundsObj.left);
516
+ const interactionHint = {
517
+ kind: 'slider',
518
+ axis: isVertical ? 'vertical' : 'horizontal',
519
+ trackBounds: boundsObj
520
+ };
521
+ outEl.interactionHint = interactionHint;
522
+ }
437
523
  const scoreVal = Math.min(1, Number(bestScore.toFixed(3)));
438
524
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal };
439
525
  }
@@ -6,7 +6,7 @@ import { handleToolCall } from './server/tool-handlers.js';
6
6
  export { wrapResponse, toolDefinitions, handleToolCall };
7
7
  export const serverInfo = {
8
8
  name: 'mobile-debug-mcp',
9
- version: '0.24.7'
9
+ version: '0.24.8'
10
10
  };
11
11
  export function createServer() {
12
12
  const server = new Server(serverInfo, {
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.24.8]
6
+ - Improved slider interaction
7
+
5
8
  ## [0.24.7]
6
9
  - Aligned runtime metadata with the published package version.
7
10
  - Fixed stale CLI helper paths in npm scripts and the `idb` healthcheck helper.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.24.7",
3
+ "version": "0.24.8",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,7 @@ interface UiElement {
36
36
  parentId?: number | string | null
37
37
  _index?: number
38
38
  _interactable?: boolean
39
+ _sliderLike?: boolean
39
40
  }
40
41
 
41
42
  interface ResolvedUiElementContext {
@@ -46,9 +47,22 @@ interface ResolvedUiElementContext {
46
47
  index: number
47
48
  }
48
49
 
50
+ interface UiResolution {
51
+ width?: number
52
+ height?: number
53
+ }
54
+
49
55
 
50
56
  export class ToolsInteract {
51
57
  private static readonly _maxResolvedUiElements = 256
58
+ private static readonly _sliderSearchLookahead = 8
59
+ private static readonly _sliderNegativeGapTolerancePx = 32
60
+ private static readonly _sliderPositiveGapLimitPx = 640
61
+ private static readonly _sliderTrackMinLengthPx = 220
62
+ private static readonly _sliderTrackMaxThicknessPx = 180
63
+ private static readonly _sliderTrackLengthRatio = 0.18
64
+ private static readonly _sliderTrackThicknessRatio = 0.08
65
+ private static readonly _sliderLabelWidthRatio = 1.5
52
66
  private static _resolvedUiElements = new Map<string, ResolvedUiElementContext>()
53
67
 
54
68
  private static _normalize(s: any): string {
@@ -240,6 +254,78 @@ export class ToolsInteract {
240
254
  return best
241
255
  }
242
256
 
257
+ private static _resolveNearbyActionableControl(
258
+ elements: UiElement[],
259
+ chosen: { el: UiElement, idx: number } | null,
260
+ screen?: UiResolution | null
261
+ ): { el: UiElement, idx: number, sliderLike?: boolean } | null {
262
+ if (!chosen) return null
263
+
264
+ const labelBounds = ToolsInteract._normalizeBounds(chosen.el.bounds)
265
+ if (!labelBounds) return null
266
+
267
+ const [labelLeft, labelTop, labelRight, labelBottom] = labelBounds
268
+ const labelWidth = labelRight - labelLeft
269
+ const labelHeight = labelBottom - labelTop
270
+ const screenWidth = Number(screen?.width) > 0 ? Number(screen?.width) : 0
271
+ const screenHeight = Number(screen?.height) > 0 ? Number(screen?.height) : 0
272
+ const minTrackLengthPx = Math.max(
273
+ ToolsInteract._sliderTrackMinLengthPx,
274
+ screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackLengthRatio) : 0,
275
+ screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackLengthRatio) : 0
276
+ )
277
+ const maxTrackThicknessPx = Math.max(
278
+ ToolsInteract._sliderTrackMaxThicknessPx,
279
+ screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackThicknessRatio) : 0,
280
+ screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackThicknessRatio) : 0
281
+ )
282
+
283
+ let best: { el: UiElement, idx: number, sliderLike?: boolean } | null = null
284
+ let bestScore = Infinity
285
+
286
+ for (let i = chosen.idx + 1; i < Math.min(elements.length, chosen.idx + ToolsInteract._sliderSearchLookahead); i++) {
287
+ const candidate = elements[i]
288
+ if (!candidate || !(candidate.clickable || candidate.focusable) || candidate.visible === false) continue
289
+
290
+ const candidateBounds = ToolsInteract._normalizeBounds(candidate.bounds)
291
+ if (!candidateBounds) continue
292
+
293
+ const [left, top, right] = candidateBounds
294
+ const width = right - left
295
+ const height = candidateBounds[3] - top
296
+ const verticalGap = top - labelBottom
297
+ if (verticalGap < -ToolsInteract._sliderNegativeGapTolerancePx || verticalGap > ToolsInteract._sliderPositiveGapLimitPx) continue
298
+
299
+ const horizontalOverlap = Math.min(labelRight, right) - Math.max(labelLeft, left)
300
+ if (horizontalOverlap < -ToolsInteract._sliderNegativeGapTolerancePx) continue
301
+
302
+ const candidateText = ToolsInteract._normalize(candidate.text ?? candidate.label ?? candidate.value ?? '')
303
+ const candidateContent = ToolsInteract._normalize(candidate.contentDescription ?? candidate.contentDesc ?? candidate.accessibilityLabel ?? '')
304
+ const candidateClass = ToolsInteract._normalize(candidate.type ?? candidate.class ?? '')
305
+
306
+ let score = verticalGap
307
+ const horizontalTrackLike =
308
+ width >= Math.max(minTrackLengthPx, Math.floor(labelWidth * ToolsInteract._sliderLabelWidthRatio)) &&
309
+ height <= maxTrackThicknessPx
310
+ const verticalTrackLike =
311
+ height >= Math.max(minTrackLengthPx, Math.floor(labelHeight * ToolsInteract._sliderLabelWidthRatio)) &&
312
+ width <= maxTrackThicknessPx
313
+ const trackLike = /slider|seek|range/i.test(candidateClass) || horizontalTrackLike || verticalTrackLike
314
+ if (!candidateText && !candidateContent) score -= 18
315
+ if (trackLike) score -= 30
316
+ if (/view|layout|group|frame/i.test(candidateClass)) score -= 10
317
+ if (width > labelWidth * ToolsInteract._sliderLabelWidthRatio) score -= 8
318
+ if (candidateText || candidateContent) score += 20
319
+
320
+ if (score < bestScore) {
321
+ bestScore = score
322
+ best = { el: candidate, idx: i, sliderLike: trackLike }
323
+ }
324
+ }
325
+
326
+ return best
327
+ }
328
+
243
329
 
244
330
  private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
245
331
  const effectivePlatform = platform || 'android'
@@ -347,6 +433,7 @@ export class ToolsInteract {
347
433
 
348
434
  let best: UiElement | null = null
349
435
  let bestScore = 0
436
+ let lastTree: any = null
350
437
 
351
438
  const scoreElement = (el: UiElement | null) => {
352
439
  if (!el || !el.visible) return 0
@@ -380,7 +467,8 @@ export class ToolsInteract {
380
467
 
381
468
  while (Date.now() <= deadline) {
382
469
  try {
383
- const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId })
470
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId })
471
+ lastTree = tree
384
472
  if (tree && Array.isArray((tree as any).elements)) {
385
473
  const elements = ((tree as any).elements as UiElement[])
386
474
  for (let i = 0; i < elements.length; i++) {
@@ -407,8 +495,8 @@ export class ToolsInteract {
407
495
 
408
496
  // If the best match is not interactable, try to resolve an actionable ancestor.
409
497
  try {
410
- const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
411
- const elements = (tree && Array.isArray(tree.elements)) ? (tree.elements as UiElement[]) : []
498
+ const elements = (lastTree && Array.isArray(lastTree.elements)) ? (lastTree.elements as UiElement[]) : []
499
+ const screen = lastTree?.resolution && typeof lastTree.resolution === 'object' ? lastTree.resolution as UiResolution : null
412
500
  let chosen = best as any
413
501
  const childBounds = Array.isArray(chosen?.bounds) ? chosen.bounds : null
414
502
 
@@ -465,6 +553,16 @@ export class ToolsInteract {
465
553
  // small score bump to reflect actionability
466
554
  bestScore = Math.min(1, bestScore + 0.02)
467
555
  }
556
+
557
+ if (best && !(best.clickable || best.focusable)) {
558
+ const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best, idx: best._index ?? elements.indexOf(best) }, screen)
559
+ if (nearbyActionable) {
560
+ best = nearbyActionable.el
561
+ best._index = nearbyActionable.idx
562
+ best._interactable = true
563
+ best._sliderLike = nearbyActionable.sliderLike
564
+ }
565
+ }
468
566
  } catch (e) { console.error('Error resolving ancestor:', e) }
469
567
 
470
568
  if (!best) return { found: false, error: 'Element not found' }
@@ -483,8 +581,18 @@ export class ToolsInteract {
483
581
  tapCoordinates,
484
582
  telemetry: {
485
583
  matchedIndex: best?._index ?? null,
486
- matchedInteractable: !!best?._interactable
584
+ matchedInteractable: !!best?._interactable,
585
+ sliderLike: !!best?._sliderLike
586
+ }
587
+ }
588
+ if (best?._sliderLike) {
589
+ const isVertical = !!boundsObj && (boundsObj.bottom - boundsObj.top) > (boundsObj.right - boundsObj.left)
590
+ const interactionHint = {
591
+ kind: 'slider',
592
+ axis: isVertical ? 'vertical' : 'horizontal',
593
+ trackBounds: boundsObj
487
594
  }
595
+ ;(outEl as any).interactionHint = interactionHint
488
596
  }
489
597
  const scoreVal = Math.min(1, Number(bestScore.toFixed(3)))
490
598
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal }
@@ -13,7 +13,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
13
13
 
14
14
  export const serverInfo = {
15
15
  name: 'mobile-debug-mcp',
16
- version: '0.24.7'
16
+ version: '0.24.8'
17
17
  }
18
18
 
19
19
  export function createServer() {
@@ -75,14 +75,73 @@ async function run() {
75
75
  assert.ok(pass4, 'Child text should resolve to a clickable parent ancestor')
76
76
  process.stdout.write('Test 4: ' + (pass4 ? 'PASS' : 'FAIL') + '\n');
77
77
 
78
- // Test 5: not found
79
- ;(ToolsObserve as any).getUITreeHandler = async () => ({ device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] })
80
- const res5: any = await ToolsInteract.findElementHandler({ query: 'nope', exact: false, platform: 'android', timeoutMs: 300 })
78
+ // Test 5: duration label should resolve to the nearby slider control
79
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
80
+ device: { platform: 'android', id: 'mock' },
81
+ screen: '',
82
+ resolution: { width: 1080, height: 1920 },
83
+ elements: [
84
+ { text: 'Duration: 5 min', type: 'android.widget.TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [10,10,260,50], resourceId: null },
85
+ { text: null, type: 'android.view.View', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [10,60,1040,140], resourceId: null }
86
+ ]
87
+ })
88
+
89
+ const res5: any = await ToolsInteract.findElementHandler({ query: 'duration', exact: false, platform: 'android', timeoutMs: 300 })
81
90
  process.stdout.write('res5 ' + JSON.stringify(res5, null, 2) + '\n');
82
- const pass5 = res5.found === false
83
- assert.ok(pass5, 'Missing elements should return found=false')
91
+ const pass5 = res5.found === true && res5.element && res5.element.clickable === true && res5.element.bounds && res5.element.bounds.top === 60 && res5.element.bounds.bottom === 140
92
+ assert.ok(pass5, 'Duration label should resolve to the slider control below it')
84
93
  process.stdout.write('Test 5: ' + (pass5 ? 'PASS' : 'FAIL') + '\n');
85
94
 
95
+ // Test 6: prefer track-like control over a closer texty sibling
96
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
97
+ device: { platform: 'android', id: 'mock' },
98
+ screen: '',
99
+ resolution: { width: 1080, height: 1920 },
100
+ elements: [
101
+ { text: 'Duration: 5 min', type: 'android.widget.TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [10,10,260,50], resourceId: null },
102
+ { text: 'Reset', type: 'android.widget.Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [10,60,150,120], resourceId: 'btn_reset' },
103
+ { text: null, type: 'android.view.View', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [10,130,1040,210], resourceId: null }
104
+ ]
105
+ })
106
+
107
+ const res6: any = await ToolsInteract.findElementHandler({ query: 'duration', exact: false, platform: 'android', timeoutMs: 300 })
108
+ process.stdout.write('res6 ' + JSON.stringify(res6, null, 2) + '\n');
109
+ const pass6 = res6.found === true && res6.element && res6.element.clickable === true && res6.element.bounds && res6.element.bounds.top === 130 && res6.element.bounds.bottom === 210
110
+ assert.ok(pass6, 'Duration lookup should prefer the track-like control over a closer text button')
111
+ process.stdout.write('Test 6: ' + (pass6 ? 'PASS' : 'FAIL') + '\n');
112
+ const pass6b = res6.element && res6.element.telemetry && res6.element.telemetry.sliderLike === true && res6.element.interactionHint && res6.element.interactionHint.kind === 'slider'
113
+ assert.ok(pass6b, 'Duration lookup should include slider-specific telemetry')
114
+ process.stdout.write('Test 6b: ' + (pass6b ? 'PASS' : 'FAIL') + '\n');
115
+
116
+ // Test 7: prefer vertical track-like control over a closer text button
117
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
118
+ device: { platform: 'android', id: 'mock' },
119
+ screen: '',
120
+ resolution: { width: 1080, height: 2400 },
121
+ elements: [
122
+ { text: 'Duration: 5 min', type: 'android.widget.TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [10,10,260,50], resourceId: null },
123
+ { text: 'Reset', type: 'android.widget.Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [10,60,150,120], resourceId: 'btn_reset' },
124
+ { text: null, type: 'android.view.View', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [270,20,350,1040], resourceId: null }
125
+ ]
126
+ })
127
+
128
+ const res7: any = await ToolsInteract.findElementHandler({ query: 'duration', exact: false, platform: 'android', timeoutMs: 300 })
129
+ process.stdout.write('res7 ' + JSON.stringify(res7, null, 2) + '\n');
130
+ const pass7 = res7.found === true && res7.element && res7.element.clickable === true && res7.element.bounds && res7.element.bounds.left === 270 && res7.element.bounds.right === 350
131
+ assert.ok(pass7, 'Duration lookup should prefer a vertical track-like control')
132
+ process.stdout.write('Test 7: ' + (pass7 ? 'PASS' : 'FAIL') + '\n');
133
+ const pass7b = res7.element && res7.element.interactionHint && res7.element.interactionHint.axis === 'vertical'
134
+ assert.ok(pass7b, 'Vertical sliders should report a vertical interaction axis')
135
+ process.stdout.write('Test 7b: ' + (pass7b ? 'PASS' : 'FAIL') + '\n');
136
+
137
+ // Test 8: not found
138
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({ device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] })
139
+ const res8: any = await ToolsInteract.findElementHandler({ query: 'nope', exact: false, platform: 'android', timeoutMs: 300 })
140
+ process.stdout.write('res8 ' + JSON.stringify(res8, null, 2) + '\n');
141
+ const pass8 = res8.found === false
142
+ assert.ok(pass8, 'Missing elements should return found=false')
143
+ process.stdout.write('Test 8: ' + (pass8 ? 'PASS' : 'FAIL') + '\n');
144
+
86
145
  } finally {
87
146
  ;(ToolsObserve as any).getUITreeHandler = origGetTree
88
147
  }