@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.
- package/lib/commonjs/highlight-updates/utils/HighlightUpdatesController.js +10 -2
- package/lib/commonjs/highlight-updates/utils/screenElements.js +721 -0
- package/lib/commonjs/sync/highlightUpdatesSyncAdapter.js +10 -1
- package/lib/module/highlight-updates/utils/HighlightUpdatesController.js +10 -2
- package/lib/module/highlight-updates/utils/screenElements.js +717 -0
- package/lib/module/sync/highlightUpdatesSyncAdapter.js +10 -1
- package/lib/typescript/highlight-updates/utils/HighlightUpdatesController.d.ts.map +1 -1
- package/lib/typescript/highlight-updates/utils/screenElements.d.ts +96 -0
- package/lib/typescript/highlight-updates/utils/screenElements.d.ts.map +1 -0
- package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts +2 -0
- package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts.map +1 -1
- package/package.json +7 -7
|
@@ -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
|
+
}
|