@buoy-gg/highlight-updates 4.0.1 → 4.0.2

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.
@@ -0,0 +1,721 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.describeScreen = describeScreen;
7
+ exports.tapElement = tapElement;
8
+ var _reactNative = require("react-native");
9
+ /**
10
+ * On-device screen element discovery + in-JS tap.
11
+ *
12
+ * This powers the MCP "describe_screen" / "tap_element" tools. It walks the
13
+ * live React fiber tree (all renderers), collects the meaningful / interactive
14
+ * elements on the current screen, and measures each one's on-screen rect — the
15
+ * same idea as the desktop screenshot tool's component-locate, but producing a
16
+ * whole-screen list with normalized tap coordinates instead of a single crop.
17
+ *
18
+ * Tapping is done PURELY in JS: we resolve the target element, find the nearest
19
+ * `onPress` handler up its fiber chain, and invoke it with a synthetic press
20
+ * event. No screenshots, no OS-level touch injection, and it works on physical
21
+ * devices — because Buoy runs INSIDE the app (unlike external simulator drivers
22
+ * that have to inject an OS touch at a pixel coordinate).
23
+ *
24
+ * Fiber traversal is adapted from image-overlay/utils/fiberScanner.ts and
25
+ * debug-borders/utils/fiberTreeTraversal.js; the fiber-access triad matches the
26
+ * rest of highlight-updates (Fabric / Paper / RN-CLI-dev).
27
+ */
28
+
29
+ /* eslint-disable @typescript-eslint/no-explicit-any */
30
+
31
+ // --- React fiber tag / renderer plumbing ---------------------------------
32
+
33
+ const HostComponent = 5;
34
+ const OffscreenComponent = 22;
35
+
36
+ // Component names that are pure infrastructure and never worth listing on
37
+ // their own. Kept small on purpose — a candidate still needs a real signal
38
+ // (onPress / testID / label / text / role) to be emitted, so this only
39
+ // suppresses the *name* of wrappers that happen to also carry one.
40
+ const INTERNAL_NAMES = new Set(["View", "RCTView", "ScrollView", "RCTScrollView", "RCTScrollContentView", "SafeAreaView", "RCTSafeAreaView", "AndroidHorizontalScrollContentView", "KeyboardAvoidingView", "Pressable", "PlatformPressable", "TouchableOpacity", "TouchableHighlight", "TouchableWithoutFeedback", "TouchableNativeFeedback", "AnimatedComponent", "Wrapper"]);
41
+ function getReactDevToolsHook() {
42
+ if (typeof global === "undefined") return null;
43
+ return global.__REACT_DEVTOOLS_GLOBAL_HOOK__ ?? null;
44
+ }
45
+
46
+ /**
47
+ * Collect fiber roots across ALL renderers. A single renderer id is not safe
48
+ * to assume: secondary reconcilers (react-native-skia, react-native-svg, …)
49
+ * register their own renderer, sometimes first as id 1, whose roots contain
50
+ * none of the tappable app UI.
51
+ */
52
+ function getAllFiberRoots() {
53
+ const hook = getReactDevToolsHook();
54
+ if (!hook) return [];
55
+ const roots = [];
56
+ try {
57
+ if (hook.renderers && typeof hook.renderers.forEach === "function") {
58
+ hook.renderers.forEach((_r, rendererId) => {
59
+ try {
60
+ const set = hook.getFiberRoots?.(rendererId);
61
+ if (set) set.forEach(root => roots.push(root));
62
+ } catch {
63
+ /* ignore a single renderer failing */
64
+ }
65
+ });
66
+ }
67
+ if (roots.length === 0 && hook.getFiberRoots) {
68
+ const legacy = hook.getFiberRoots(1);
69
+ if (legacy) legacy.forEach(root => roots.push(root));
70
+ }
71
+ } catch {
72
+ /* ignore */
73
+ }
74
+ return roots;
75
+ }
76
+ function isHiddenOffscreen(fiber) {
77
+ return fiber.tag === OffscreenComponent && fiber.memoizedState !== null;
78
+ }
79
+ function isInactiveScreen(fiber) {
80
+ // react-native-screens: activityState 0/1 = inactive/transitioning.
81
+ const as = fiber.memoizedProps?.activityState;
82
+ if (as === 0 || as === 1) return true;
83
+ // pagers
84
+ if (fiber.memoizedProps?.isPageFocused === false) return true;
85
+ return false;
86
+ }
87
+ function traverseFiberTree(fiber, callback, depth = 0, visited = new Set()) {
88
+ if (!fiber || visited.has(fiber) || depth > 500) return;
89
+ visited.add(fiber);
90
+ if (isHiddenOffscreen(fiber) || isInactiveScreen(fiber)) {
91
+ // Prune the whole subtree, but keep walking siblings.
92
+ if (fiber.sibling) traverseFiberTree(fiber.sibling, callback, depth, visited);
93
+ return;
94
+ }
95
+ callback(fiber);
96
+ if (fiber.child) traverseFiberTree(fiber.child, callback, depth + 1, visited);
97
+ if (fiber.sibling) traverseFiberTree(fiber.sibling, callback, depth, visited);
98
+ }
99
+
100
+ // --- Fiber → host instance / name / props --------------------------------
101
+
102
+ /** Get a measurable public instance from a host fiber's stateNode. */
103
+ function getPublicInstance(stateNode) {
104
+ if (!stateNode) return null;
105
+ const node = stateNode;
106
+ if (node.canonical?.publicInstance) return node.canonical.publicInstance;
107
+ if (node.canonical && typeof node.canonical.measure === "function") {
108
+ return node.canonical;
109
+ }
110
+ if (typeof node.measure === "function" || typeof node.measureInWindow === "function") {
111
+ return node;
112
+ }
113
+ return null;
114
+ }
115
+ function getNativeTag(stateNode) {
116
+ if (stateNode == null) return null;
117
+ const n = stateNode;
118
+ if (typeof n.__nativeTag === "number") return n.__nativeTag;
119
+ if (typeof n._nativeTag === "number") return n._nativeTag;
120
+ if (typeof n.nativeTag === "number") return n.nativeTag;
121
+ if (n.canonical) {
122
+ if (typeof n.canonical.__nativeTag === "number") return n.canonical.__nativeTag;
123
+ if (typeof n.canonical._nativeTag === "number") return n.canonical._nativeTag;
124
+ if (typeof n.canonical.nativeTag === "number") return n.canonical.nativeTag;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /** The first host node in a fiber's own subtree (self first, then descendants). */
130
+ function findHostInstance(fiber) {
131
+ const selfInst = getPublicInstance(fiber.stateNode);
132
+ if (selfInst) {
133
+ return {
134
+ instance: selfInst,
135
+ nativeTag: getNativeTag(fiber.stateNode),
136
+ stateNode: fiber.stateNode
137
+ };
138
+ }
139
+
140
+ // DFS restricted to this fiber's subtree (child + siblings-of-children).
141
+ const stack = [];
142
+ if (fiber.child) stack.push(fiber.child);
143
+ let guard = 0;
144
+ while (stack.length > 0 && guard < 4000) {
145
+ guard++;
146
+ const f = stack.pop();
147
+ if (!f) continue;
148
+ const inst = getPublicInstance(f.stateNode);
149
+ if (inst) return {
150
+ instance: inst,
151
+ nativeTag: getNativeTag(f.stateNode),
152
+ stateNode: f.stateNode
153
+ };
154
+ if (f.child) stack.push(f.child);
155
+ if (f.sibling) stack.push(f.sibling);
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * Walk up from a fiber to the nearest ScrollView-like instance (exposes
162
+ * scrollTo). Mirrors HighlightUpdatesController.findScrollAncestor.
163
+ */
164
+ function findScrollAncestor(fiber) {
165
+ let cur = fiber;
166
+ let guard = 0;
167
+ while (cur && guard++ < 200) {
168
+ const inst = cur.stateNode;
169
+ if (inst && typeof inst.scrollTo === "function") {
170
+ const nativeRef = typeof inst.getNativeScrollRef === "function" && inst.getNativeScrollRef() || null;
171
+ const innerNode = typeof inst.getInnerViewNode === "function" && inst.getInnerViewNode() || typeof inst.getScrollableNode === "function" && inst.getScrollableNode() || inst;
172
+ return {
173
+ instance: inst,
174
+ nativeRef,
175
+ innerNode
176
+ };
177
+ }
178
+ cur = cur.return;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Best-effort scroll of an ancestor ScrollView so the target sits near the top
185
+ * of its viewport. Returns true only if a scroll was issued. Never throws.
186
+ * Ported from HighlightUpdatesController.scrollDescendantIntoView.
187
+ */
188
+ async function scrollIntoView(targetFiber, publicInstance, stateNode, margin) {
189
+ try {
190
+ const ancestor = findScrollAncestor(targetFiber);
191
+ if (!ancestor) return false;
192
+ const inst = publicInstance;
193
+ let top = null;
194
+
195
+ // measureLayout can invoke NEITHER callback on some architectures — race
196
+ // every call against a short timeout so the await can't hang.
197
+ const withTimeout = run => new Promise(resolve => {
198
+ let done = false;
199
+ const settle = v => {
200
+ if (done) return;
201
+ done = true;
202
+ resolve(v);
203
+ };
204
+ const timer = setTimeout(() => settle(null), 600);
205
+ try {
206
+ run(v => {
207
+ clearTimeout(timer);
208
+ settle(v);
209
+ });
210
+ } catch {
211
+ clearTimeout(timer);
212
+ settle(null);
213
+ }
214
+ });
215
+
216
+ // Fabric: ref.measureLayout against the inner view ref.
217
+ if (ancestor.nativeRef && typeof inst.measureLayout === "function") {
218
+ top = await withTimeout(resolve => inst.measureLayout(ancestor.nativeRef, (_left, t) => resolve(t), () => resolve(null)));
219
+ }
220
+
221
+ // Legacy: UIManager.measureLayout with numeric node handles.
222
+ if (top == null) {
223
+ const targetHandle = (0, _reactNative.findNodeHandle)(publicInstance);
224
+ const innerHandle = typeof ancestor.innerNode === "number" ? ancestor.innerNode : (0, _reactNative.findNodeHandle)(ancestor.innerNode);
225
+ if (targetHandle != null && innerHandle != null) {
226
+ top = await withTimeout(resolve => _reactNative.UIManager.measureLayout(targetHandle, innerHandle, () => resolve(null), (_left, t) => resolve(t)));
227
+ }
228
+ }
229
+ if (top == null) return false;
230
+ ancestor.instance.scrollTo({
231
+ y: Math.max(0, top - margin),
232
+ animated: false
233
+ });
234
+ await new Promise(r => setTimeout(r, 120)); // let scroll + layout settle
235
+ return true;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+ function getComponentName(fiber) {
241
+ const t = fiber?.type;
242
+ if (!t) return null;
243
+ if (typeof t === "string") return t;
244
+ return t.displayName || t.name || null;
245
+ }
246
+
247
+ /** Walk up to the nearest user-defined component name (skips host/internal). */
248
+ function getOwningComponentName(fiber) {
249
+ let cur = fiber?._debugOwner || fiber?.return;
250
+ let depth = 0;
251
+ while (cur && depth < 30) {
252
+ depth++;
253
+ const name = getComponentName(cur);
254
+ if (name && !INTERNAL_NAMES.has(name) && typeof cur.type !== "string") {
255
+ return name;
256
+ }
257
+ cur = cur.return;
258
+ }
259
+ return null;
260
+ }
261
+ function getProps(fiber) {
262
+ return fiber?.memoizedProps ?? fiber?.pendingProps ?? null;
263
+ }
264
+ function getVisibleText(props) {
265
+ if (!props) return undefined;
266
+ const pick = v => {
267
+ if (typeof v === "string") return v.trim() ? v.trim().slice(0, 80) : undefined;
268
+ // Numeric children are common (counters, badges) — coerce them too.
269
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
270
+ return undefined;
271
+ };
272
+ return pick(props.children) ?? pick(props.title) ?? pick(props.label) ?? pick(props.placeholder) ?? undefined;
273
+ }
274
+
275
+ /**
276
+ * Gather visible text from a fiber's descendants (its own label), stopping at
277
+ * nested interactive subtrees so a button doesn't absorb another button's text.
278
+ * Lets an icon-button whose label is a child <Text> (e.g. a dial icon) still be
279
+ * matched/shown by that label — the button host measures even when the inner
280
+ * Text host doesn't.
281
+ */
282
+ function collectDescendantText(root, maxDepth) {
283
+ const parts = [];
284
+ const visit = (f, depth) => {
285
+ if (!f || depth > maxDepth || parts.length >= 4) return;
286
+ // Don't cross into a nested interactive element (another button's label).
287
+ if (f !== root) {
288
+ const p = getProps(f);
289
+ if (p && (typeof p.onPress === "function" || typeof p.onLongPress === "function")) return;
290
+ }
291
+ const t = getVisibleText(getProps(f));
292
+ if (t && !parts.includes(t)) parts.push(t);
293
+ if (f.child) visit(f.child, depth + 1);
294
+ if (f !== root && f.sibling) visit(f.sibling, depth); // siblings stay in subtree
295
+ };
296
+ if (root.child) visit(root.child, 1);
297
+ const joined = parts.join(" ").trim();
298
+ return joined ? joined.slice(0, 80) : undefined;
299
+ }
300
+
301
+ /** Nearest onPress (then onLongPress) handler at or above a fiber. */
302
+ function findPressHandler(fiber) {
303
+ let cur = fiber;
304
+ let depth = 0;
305
+ let longPress = null;
306
+ while (cur && depth < 25) {
307
+ const p = getProps(cur);
308
+ if (p) {
309
+ if (typeof p.onPress === "function") return {
310
+ handler: p.onPress,
311
+ kind: "onPress"
312
+ };
313
+ if (!longPress && typeof p.onLongPress === "function") longPress = p.onLongPress;
314
+ }
315
+ cur = cur.return;
316
+ depth++;
317
+ }
318
+ return longPress ? {
319
+ handler: longPress,
320
+ kind: "onLongPress"
321
+ } : null;
322
+ }
323
+
324
+ // --- Measurement ----------------------------------------------------------
325
+
326
+ /** Absolute on-screen rect in points (or null), with a hang guard. */
327
+ function measureInstance(instance) {
328
+ return new Promise(resolve => {
329
+ let settled = false;
330
+ const done = r => {
331
+ if (settled) return;
332
+ settled = true;
333
+ resolve(r);
334
+ };
335
+ // Detached / off-screen nodes can fire neither callback.
336
+ setTimeout(() => done(null), 1000);
337
+ try {
338
+ if (typeof instance.measureInWindow === "function") {
339
+ instance.measureInWindow((x, y, width, height) => {
340
+ if (width > 0 && height > 0) done({
341
+ x,
342
+ y,
343
+ width,
344
+ height
345
+ });else done(null);
346
+ });
347
+ } else if (typeof instance.measure === "function") {
348
+ instance.measure((_x, _y, width, height, pageX, pageY) => {
349
+ if (width > 0 && height > 0 && pageX != null && pageY != null) {
350
+ done({
351
+ x: pageX,
352
+ y: pageY,
353
+ width,
354
+ height
355
+ });
356
+ } else {
357
+ done(null);
358
+ }
359
+ });
360
+ } else {
361
+ done(null);
362
+ }
363
+ } catch {
364
+ done(null);
365
+ }
366
+ });
367
+ }
368
+
369
+ // --- Public types ---------------------------------------------------------
370
+
371
+ // Internal record carries the live fiber refs that `describeScreen`'s
372
+ // serializable result drops.
373
+
374
+ // --- Collection -----------------------------------------------------------
375
+
376
+ function collectCandidates() {
377
+ const roots = getAllFiberRoots();
378
+ if (roots.length === 0) return [];
379
+ const out = [];
380
+ const seenInstances = new Set();
381
+ for (const root of roots) {
382
+ traverseFiberTree(root.current, fiber => {
383
+ const props = getProps(fiber);
384
+ if (!props) return;
385
+ const hasPress = typeof props.onPress === "function" || typeof props.onLongPress === "function";
386
+ const hasValue = typeof props.onValueChange === "function";
387
+ const hasText = typeof props.onChangeText === "function";
388
+ const testID = props.testID || undefined;
389
+ const label = props.accessibilityLabel || undefined;
390
+ const role = props.accessibilityRole || props.role || undefined;
391
+ let text = getVisibleText(props);
392
+ const roleInteractive = role === "button" || role === "link" || role === "menuitem" || role === "tab" || role === "switch" || role === "checkbox";
393
+
394
+ // A fiber earns a row only if it carries a real signal.
395
+ if (!hasPress && !hasValue && !hasText && !testID && !label && !text && !roleInteractive) {
396
+ return;
397
+ }
398
+
399
+ // Resolve how this element is driven. A value/text control is a leaf
400
+ // (Switch/Slider/TextInput) whose OWN handler wins over an ancestor
401
+ // onPress; otherwise fall back to the nearest onPress up the tree.
402
+ let press = null;
403
+ let valueControl = null;
404
+ let textControl = null;
405
+ if (hasValue) {
406
+ const current = props.value;
407
+ const numeric = typeof current === "number" || typeof props.minimumValue === "number";
408
+ valueControl = {
409
+ handler: props.onValueChange,
410
+ kind: numeric ? "slider" : "toggle",
411
+ current,
412
+ min: typeof props.minimumValue === "number" ? props.minimumValue : undefined,
413
+ max: typeof props.maximumValue === "number" ? props.maximumValue : undefined
414
+ };
415
+ } else if (hasText) {
416
+ textControl = {
417
+ handler: props.onChangeText,
418
+ current: typeof props.value === "string" ? props.value : undefined
419
+ };
420
+ } else {
421
+ press = findPressHandler(fiber);
422
+ }
423
+ const rawName = getComponentName(fiber);
424
+ // Prefer a user component name over a bare host/internal wrapper name.
425
+ let name = rawName ?? "Unknown";
426
+ if (!rawName || INTERNAL_NAMES.has(rawName) || typeof fiber.type === "string") {
427
+ name = getOwningComponentName(fiber) ?? rawName ?? "Unknown";
428
+ }
429
+ const host = findHostInstance(fiber);
430
+ if (!host && !testID) return; // unmeasurable & unnamed → skip
431
+
432
+ // Interactive element with no own text/label (icon button, dial icon) —
433
+ // adopt a descendant <Text> as its label so it's matchable by that text.
434
+ if (!text && !label && hasPress) {
435
+ text = collectDescendantText(fiber, 6);
436
+ }
437
+ const instance = host?.instance ?? null;
438
+ // De-dup: two fibers resolving to the same host instance (e.g. Pressable
439
+ // and its inner View) collapse to one row — keep the more interactive.
440
+ if (instance && seenInstances.has(instance)) {
441
+ const existing = out.find(c => c.instance === instance);
442
+ if (existing) {
443
+ if (!existing.press && press) existing.press = press;
444
+ if (!existing.valueControl && valueControl) existing.valueControl = valueControl;
445
+ if (!existing.textControl && textControl) existing.textControl = textControl;
446
+ if (!existing.testID && testID) existing.testID = testID;
447
+ if (!existing.label && label) existing.label = label;
448
+ if (!existing.text && text) existing.text = text;
449
+ return;
450
+ }
451
+ }
452
+ if (instance) seenInstances.add(instance);
453
+ out.push({
454
+ nativeTag: host?.nativeTag ?? null,
455
+ name,
456
+ role,
457
+ testID,
458
+ label,
459
+ text,
460
+ instance,
461
+ stateNode: host?.stateNode ?? null,
462
+ fiber,
463
+ press,
464
+ valueControl,
465
+ textControl,
466
+ rect: null
467
+ });
468
+ });
469
+ }
470
+ return out;
471
+ }
472
+ async function measureCandidates(candidates) {
473
+ await Promise.all(candidates.map(async c => {
474
+ if (c.instance) c.rect = await measureInstance(c.instance);
475
+ }));
476
+ }
477
+
478
+ // --- describeScreen -------------------------------------------------------
479
+
480
+ async function describeScreen() {
481
+ const candidates = collectCandidates();
482
+ await measureCandidates(candidates);
483
+ const win = _reactNative.Dimensions.get("window");
484
+ const screenW = Math.round(win.width) || 1;
485
+ const screenH = Math.round(win.height) || 1;
486
+ const elements = [];
487
+ for (const c of candidates) {
488
+ const r = c.rect;
489
+ // Keep on-screen elements; drop rect-less rows unless they carry a testID.
490
+ if (!r && !c.testID) continue;
491
+ let tap;
492
+ let frame;
493
+ if (r) {
494
+ const cx = r.x + r.width / 2;
495
+ const cy = r.y + r.height / 2;
496
+ // Skip elements entirely off-screen.
497
+ if (cy < -screenH * 0.1 || cy > screenH * 1.1 || cx < -screenW * 0.1 || cx > screenW * 1.1) {
498
+ if (!c.testID) continue;
499
+ }
500
+ tap = {
501
+ x: round2(cx / screenW),
502
+ y: round2(cy / screenH)
503
+ };
504
+ frame = {
505
+ x: round2(r.x / screenW),
506
+ y: round2(r.y / screenH),
507
+ width: round2(r.width / screenW),
508
+ height: round2(r.height / screenH)
509
+ };
510
+ }
511
+ const control = c.valueControl ? c.valueControl.kind : c.textControl ? "text" : undefined;
512
+ const value = c.valueControl ? c.valueControl.current : c.textControl?.current;
513
+ elements.push({
514
+ nativeTag: c.nativeTag,
515
+ name: c.name,
516
+ role: c.role,
517
+ testID: c.testID,
518
+ label: c.label,
519
+ text: c.text,
520
+ interactive: !!(c.press || c.valueControl || c.textControl),
521
+ control,
522
+ value,
523
+ tap,
524
+ frame
525
+ });
526
+ }
527
+
528
+ // Reading order: top-to-bottom, then left-to-right.
529
+ elements.sort((a, b) => {
530
+ const ay = a.tap?.y ?? 2;
531
+ const by = b.tap?.y ?? 2;
532
+ if (Math.abs(ay - by) > 0.02) return ay - by;
533
+ return (a.tap?.x ?? 2) - (b.tap?.x ?? 2);
534
+ });
535
+ return {
536
+ screen: {
537
+ width: screenW,
538
+ height: screenH
539
+ },
540
+ count: elements.length,
541
+ elements
542
+ };
543
+ }
544
+ function round2(n) {
545
+ return Math.round(n * 100) / 100;
546
+ }
547
+
548
+ // --- tapElement -----------------------------------------------------------
549
+
550
+ function matchScore(c, params) {
551
+ if (params.nativeTag != null && c.nativeTag === params.nativeTag) return 1000;
552
+ const q = (params.query ?? params.testID ?? "").trim().toLowerCase();
553
+ if (!q) return 0;
554
+ const fields = [c.testID, c.label, c.text, c.name].filter(Boolean).map(s => s.toLowerCase());
555
+ let best = 0;
556
+ for (const f of fields) {
557
+ if (f === q) best = Math.max(best, 100);else if (f.startsWith(q)) best = Math.max(best, 50);else if (f.includes(q)) best = Math.max(best, 25);
558
+ }
559
+ // testID exact match should win over a fuzzy name hit.
560
+ if (params.testID && c.testID && c.testID.toLowerCase() === params.testID.toLowerCase()) {
561
+ best = Math.max(best, 200);
562
+ }
563
+ return best;
564
+ }
565
+ function makeSyntheticPressEvent(rect) {
566
+ const pageX = rect ? rect.x + rect.width / 2 : 0;
567
+ const pageY = rect ? rect.y + rect.height / 2 : 0;
568
+ const nativeEvent = {
569
+ locationX: rect ? rect.width / 2 : 0,
570
+ locationY: rect ? rect.height / 2 : 0,
571
+ pageX,
572
+ pageY,
573
+ timestamp: 0,
574
+ target: null,
575
+ identifier: 1,
576
+ touches: [],
577
+ changedTouches: []
578
+ };
579
+ return {
580
+ nativeEvent,
581
+ persist: () => {},
582
+ preventDefault: () => {},
583
+ stopPropagation: () => {},
584
+ isDefaultPrevented: () => false,
585
+ isPropagationStopped: () => false,
586
+ target: null,
587
+ currentTarget: null,
588
+ type: "press",
589
+ timeStamp: 0
590
+ };
591
+ }
592
+ async function tapElement(params) {
593
+ const candidates = collectCandidates();
594
+ if (candidates.length === 0) {
595
+ return {
596
+ tapped: false,
597
+ reason: "No React fiber roots / elements found on screen."
598
+ };
599
+ }
600
+
601
+ // Rank; only elements with a reachable press handler are tappable.
602
+ const ranked = candidates.map(c => ({
603
+ c,
604
+ score: matchScore(c, params)
605
+ })).filter(r => r.score > 0).sort((a, b) => b.score - a.score);
606
+ const isInteractive = c => !!(c.press || c.valueControl || c.textControl);
607
+ if (ranked.length === 0) {
608
+ return {
609
+ tapped: false,
610
+ reason: "No element matched. Call describe_screen to see available elements (match by testID, text, or name).",
611
+ candidates: candidates.filter(isInteractive).slice(0, 12).map(c => ({
612
+ nativeTag: c.nativeTag,
613
+ name: c.name,
614
+ testID: c.testID,
615
+ text: c.text
616
+ }))
617
+ };
618
+ }
619
+ const target = ranked.find(r => isInteractive(r.c)) ?? ranked[0];
620
+ const c = target.c;
621
+ if (!isInteractive(c)) {
622
+ return {
623
+ tapped: false,
624
+ reason: `Matched "${c.name}"${c.text ? ` ("${c.text}")` : ""} but it has no onPress / onValueChange / onChangeText handler (e.g. a react-native-gesture-handler GestureDetector). This element can't be driven in JS.`,
625
+ matched: {
626
+ nativeTag: c.nativeTag,
627
+ name: c.name,
628
+ testID: c.testID,
629
+ label: c.label,
630
+ text: c.text,
631
+ via: "onPress"
632
+ }
633
+ };
634
+ }
635
+
636
+ // Measure the target so we can decide whether to scroll it on-screen (and,
637
+ // for a press, give the synthetic event real coordinates).
638
+ if (c.instance) c.rect = await measureInstance(c.instance);
639
+
640
+ // Bring it into view if it's off-screen and scrolling is wanted. The action
641
+ // doesn't NEED this (handlers are invoked directly), but it keeps the element
642
+ // visible for the human watching / a follow-up describe.
643
+ let scrolled = false;
644
+ if (params.scrollIntoView !== false && c.instance) {
645
+ const win = _reactNative.Dimensions.get("window");
646
+ const centerY = c.rect ? c.rect.y + c.rect.height / 2 : null;
647
+ const offScreen = c.rect == null || centerY < 0 || centerY > win.height;
648
+ if (offScreen) {
649
+ scrolled = await scrollIntoView(c.fiber, c.instance, c.stateNode, 80);
650
+ if (scrolled) c.rect = await measureInstance(c.instance); // fresh coords
651
+ }
652
+ }
653
+ const base = {
654
+ nativeTag: c.nativeTag,
655
+ name: c.name,
656
+ testID: c.testID,
657
+ label: c.label,
658
+ text: c.text
659
+ };
660
+ try {
661
+ if (c.press) {
662
+ c.press.handler(makeSyntheticPressEvent(c.rect));
663
+ return {
664
+ tapped: true,
665
+ scrolled,
666
+ matched: {
667
+ ...base,
668
+ via: c.press.kind
669
+ }
670
+ };
671
+ }
672
+ if (c.valueControl) {
673
+ const vc = c.valueControl;
674
+ let next;
675
+ if (vc.kind === "toggle") {
676
+ next = typeof params.value === "boolean" ? params.value : !vc.current;
677
+ } else {
678
+ const min = vc.min ?? 0;
679
+ const max = vc.max ?? 1;
680
+ if (typeof params.value === "number") {
681
+ next = Math.min(max, Math.max(min, params.value));
682
+ } else {
683
+ // No target given → move to the far end so the change is visible.
684
+ next = vc.current === max ? min : max;
685
+ }
686
+ }
687
+ vc.handler(next);
688
+ return {
689
+ tapped: true,
690
+ scrolled,
691
+ matched: {
692
+ ...base,
693
+ via: "onValueChange",
694
+ value: next
695
+ }
696
+ };
697
+ }
698
+ // textControl
699
+ const next = params.text ?? (typeof params.value === "string" ? params.value : "");
700
+ c.textControl.handler(next);
701
+ return {
702
+ tapped: true,
703
+ scrolled,
704
+ matched: {
705
+ ...base,
706
+ via: "onChangeText",
707
+ value: next
708
+ }
709
+ };
710
+ } catch (e) {
711
+ return {
712
+ tapped: false,
713
+ scrolled,
714
+ reason: `handler threw: ${e instanceof Error ? e.message : String(e)}`,
715
+ matched: {
716
+ ...base,
717
+ via: c.press ? c.press.kind : c.valueControl ? "onValueChange" : "onChangeText"
718
+ }
719
+ };
720
+ }
721
+ }