execbro 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/build/__tests__/unit/screenSpace.test.d.ts +2 -0
- package/build/__tests__/unit/screenSpace.test.d.ts.map +1 -0
- package/build/__tests__/unit/screenSpace.test.js +58 -0
- package/build/__tests__/unit/screenSpace.test.js.map +1 -0
- package/build/__tests__/unit/visibility.test.js +21 -0
- package/build/__tests__/unit/visibility.test.js.map +1 -1
- package/build/core/guides.d.ts.map +1 -1
- package/build/core/guides.js +18 -4
- package/build/core/guides.js.map +1 -1
- package/build/core/index.d.ts +1 -1
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js +1 -1
- package/build/core/index.js.map +1 -1
- package/build/core/injected/visibility.d.ts +14 -1
- package/build/core/injected/visibility.d.ts.map +1 -1
- package/build/core/injected/visibility.js +28 -4
- package/build/core/injected/visibility.js.map +1 -1
- package/build/core/injectedFilters.d.ts +10 -2
- package/build/core/injectedFilters.d.ts.map +1 -1
- package/build/core/injectedFilters.js +10 -2
- package/build/core/injectedFilters.js.map +1 -1
- package/build/core/inspector.d.ts +6 -0
- package/build/core/inspector.d.ts.map +1 -1
- package/build/core/inspector.js +200 -36
- package/build/core/inspector.js.map +1 -1
- package/build/core/pressables.d.ts +0 -32
- package/build/core/pressables.d.ts.map +1 -1
- package/build/core/pressables.js +5 -1061
- package/build/core/pressables.js.map +1 -1
- package/build/core/screenLayout.d.ts +3 -0
- package/build/core/screenLayout.d.ts.map +1 -1
- package/build/core/screenLayout.js +32 -0
- package/build/core/screenLayout.js.map +1 -1
- package/build/core/screenSpace.d.ts +75 -0
- package/build/core/screenSpace.d.ts.map +1 -0
- package/build/core/screenSpace.js +84 -0
- package/build/core/screenSpace.js.map +1 -0
- package/build/core/screenSpaceDevice.d.ts +15 -0
- package/build/core/screenSpaceDevice.d.ts.map +1 -0
- package/build/core/screenSpaceDevice.js +24 -0
- package/build/core/screenSpaceDevice.js.map +1 -0
- package/build/core/screenState.d.ts.map +1 -1
- package/build/core/screenState.js +107 -8
- package/build/core/screenState.js.map +1 -1
- package/build/tools/componentTools.d.ts.map +1 -1
- package/build/tools/componentTools.js +54 -186
- package/build/tools/componentTools.js.map +1 -1
- package/build/tools/screenshotTools.d.ts.map +1 -1
- package/build/tools/screenshotTools.js +37 -72
- package/build/tools/screenshotTools.js.map +1 -1
- package/package.json +1 -1
- package/tools.json +1 -5
package/build/core/pressables.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { executeInApp, delay } from "./jsExecute.js";
|
|
2
|
-
import { iconLabel } from "./iconSemantics.js";
|
|
3
|
-
import { getConnectedAppByDevice, getFirstConnectedApp, getConnectedAppBySimulatorUdid, getConnectedAppByAndroidDeviceId } from "./connection.js";
|
|
4
2
|
import { VISIBILITY_HELPERS_JS } from "./injected/visibility.js";
|
|
3
|
+
import { RN_PRIMITIVES_SRC } from "./injectedFilters.js";
|
|
5
4
|
// ============================================================================
|
|
6
5
|
// Pressable Elements & onPress invocation
|
|
7
6
|
// ============================================================================
|
|
@@ -71,1064 +70,6 @@ async function awaitMeasurements(globalNames, toolName, device) {
|
|
|
71
70
|
}
|
|
72
71
|
});
|
|
73
72
|
}
|
|
74
|
-
/**
|
|
75
|
-
* E1 / Step 5 (2026-05-15 plan): build a pressables list from the platform
|
|
76
|
-
* accessibility tree when fiber isn't reachable. Used when metro is offline
|
|
77
|
-
* (connectedApps.size === 0) so the screenshot tools can still hand the agent
|
|
78
|
-
* labelled coordinates instead of forcing it to guess pixels — the dominant
|
|
79
|
-
* cause of unmeaningful coordinate-strategy taps in the 7-day failure data.
|
|
80
|
-
*/
|
|
81
|
-
async function getAccessibilityPressables(platform, udid) {
|
|
82
|
-
try {
|
|
83
|
-
if (platform === "ios") {
|
|
84
|
-
const { iosGetUITree } = await import("./ios.js");
|
|
85
|
-
const tree = await iosGetUITree(udid);
|
|
86
|
-
if (!tree.success || !tree.elements)
|
|
87
|
-
return [];
|
|
88
|
-
const interactiveTraits = new Set(["button", "link", "searchfield", "tab", "image"]);
|
|
89
|
-
return tree.elements
|
|
90
|
-
.filter(el => {
|
|
91
|
-
// iOS: rely on a11y traits + element type. Buttons, links,
|
|
92
|
-
// tabs, and any element with a non-empty label/identifier
|
|
93
|
-
// that isn't a static text container.
|
|
94
|
-
const traitsLower = (el.traits || []).map(t => t.toLowerCase());
|
|
95
|
-
if (traitsLower.some(t => interactiveTraits.has(t)))
|
|
96
|
-
return true;
|
|
97
|
-
const type = (el.type || "").toLowerCase();
|
|
98
|
-
if (type.includes("button") || type.includes("textfield") || type.includes("searchfield"))
|
|
99
|
-
return true;
|
|
100
|
-
return false;
|
|
101
|
-
})
|
|
102
|
-
.map(el => {
|
|
103
|
-
const type = (el.type || "").toLowerCase();
|
|
104
|
-
return {
|
|
105
|
-
component: el.type || "Element",
|
|
106
|
-
path: "",
|
|
107
|
-
center: el.center,
|
|
108
|
-
frame: el.frame,
|
|
109
|
-
text: el.label || "",
|
|
110
|
-
testID: el.identifier || null,
|
|
111
|
-
accessibilityLabel: el.label || null,
|
|
112
|
-
hasLabel: !!el.label,
|
|
113
|
-
isInput: type.includes("textfield") || type.includes("searchfield")
|
|
114
|
-
};
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
else {
|
|
118
|
-
const { androidGetUITree } = await import("./android.js");
|
|
119
|
-
const tree = await androidGetUITree();
|
|
120
|
-
if (!tree.success || !tree.elements)
|
|
121
|
-
return [];
|
|
122
|
-
return tree.elements
|
|
123
|
-
.filter(el => el.clickable || el.focused || (el.className && el.className.includes("EditText")))
|
|
124
|
-
.map(el => ({
|
|
125
|
-
component: (el.className || "View").split(".").pop() || "View",
|
|
126
|
-
path: "",
|
|
127
|
-
center: el.center,
|
|
128
|
-
frame: { x: el.bounds.left, y: el.bounds.top, width: el.bounds.width, height: el.bounds.height },
|
|
129
|
-
text: el.text || "",
|
|
130
|
-
testID: el.resourceId || null,
|
|
131
|
-
accessibilityLabel: el.contentDesc || null,
|
|
132
|
-
hasLabel: !!(el.text || el.contentDesc),
|
|
133
|
-
isInput: !!(el.className && el.className.includes("EditText"))
|
|
134
|
-
}));
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
catch {
|
|
138
|
-
return [];
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
export async function getPressableElements(options = {}) {
|
|
142
|
-
const { device, platform, udid } = options;
|
|
143
|
-
// Resolve which connected RN app corresponds to the requested device/udid.
|
|
144
|
-
// The E1 fallback below must fire when no app matches THIS device, even if
|
|
145
|
-
// unrelated apps remain connected to Metro. Without this filter the fiber
|
|
146
|
-
// path would silently fall back to the first-connected app and return data
|
|
147
|
-
// for the wrong device (Bug 1, 2026-05-16).
|
|
148
|
-
let targetApp = null;
|
|
149
|
-
if (udid && platform === "ios") {
|
|
150
|
-
targetApp = getConnectedAppBySimulatorUdid(udid);
|
|
151
|
-
// scan_metro links simulatorUdid asynchronously after the CDP connect; in
|
|
152
|
-
// that window the udid lookup misses even though the right app is already
|
|
153
|
-
// connected. Fall back to the device-name match so callers (ios_screenshot)
|
|
154
|
-
// don't silently degrade to the accessibility-tree pressables list.
|
|
155
|
-
if (!targetApp && device) {
|
|
156
|
-
const matched = getConnectedAppByDevice(device);
|
|
157
|
-
if (matched && matched.platform === "ios")
|
|
158
|
-
targetApp = matched;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
else if (device) {
|
|
162
|
-
const matched = getConnectedAppByDevice(device);
|
|
163
|
-
if (matched && (!platform || matched.platform === platform))
|
|
164
|
-
targetApp = matched;
|
|
165
|
-
}
|
|
166
|
-
else if (platform === "android") {
|
|
167
|
-
targetApp = getConnectedAppByAndroidDeviceId(undefined);
|
|
168
|
-
}
|
|
169
|
-
else if (platform === "ios") {
|
|
170
|
-
const first = getFirstConnectedApp();
|
|
171
|
-
targetApp = first && first.platform === "ios" ? first : null;
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
targetApp = getFirstConnectedApp();
|
|
175
|
-
}
|
|
176
|
-
// When the caller didn't pin a platform, adopt the resolved app's platform so
|
|
177
|
-
// downstream branches (Android coordinate reconciliation below) run uniformly
|
|
178
|
-
// regardless of entry point. The standalone get_pressable_elements MCP tool
|
|
179
|
-
// omits `platform`; without this derivation it would skip reconciliation and
|
|
180
|
-
// return raw fiber DP that downstream consumers misinterpret as device pixels.
|
|
181
|
-
const effectivePlatform = platform ?? targetApp?.platform;
|
|
182
|
-
// E1 fallback: no app resolved for THIS device → use platform accessibility tree.
|
|
183
|
-
// Only kicks in when caller passed `platform` so we don't break older
|
|
184
|
-
// metro-required call paths that didn't opt into the fallback.
|
|
185
|
-
if (!targetApp && platform) {
|
|
186
|
-
const elements = await getAccessibilityPressables(platform, udid);
|
|
187
|
-
return {
|
|
188
|
-
success: true,
|
|
189
|
-
result: elements.length > 0
|
|
190
|
-
? `Pressable elements (from accessibility tree, ${elements.length} found):\n` +
|
|
191
|
-
elements.map((el, i) => {
|
|
192
|
-
const label = el.accessibilityLabel || el.text || el.testID || el.component;
|
|
193
|
-
const idStr = el.testID ? ` testID="${el.testID}"` : "";
|
|
194
|
-
const inputStr = el.isInput ? " [input]" : "";
|
|
195
|
-
return `${i + 1}. ${el.component} "${label}"${idStr}${inputStr} — center:(${el.center.x},${el.center.y}) frame:(${el.frame.x},${el.frame.y} ${el.frame.width}x${el.frame.height})`;
|
|
196
|
-
}).join("\n")
|
|
197
|
-
: "No interactive elements found via accessibility.",
|
|
198
|
-
parsedElements: elements
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
// --- Step 1: walk fiber tree, find pressable/input elements, dispatch measureInWindow ---
|
|
202
|
-
const dispatchExpression = `
|
|
203
|
-
(function() {
|
|
204
|
-
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
205
|
-
if (!hook) return { error: 'React DevTools hook not found.' };
|
|
206
|
-
|
|
207
|
-
var roots = [];
|
|
208
|
-
if (hook.getFiberRoots) {
|
|
209
|
-
roots = Array.from(hook.getFiberRoots(1) || []);
|
|
210
|
-
}
|
|
211
|
-
if (roots.length === 0 && hook.renderers) {
|
|
212
|
-
for (var entry of hook.renderers) {
|
|
213
|
-
var r = Array.from(hook.getFiberRoots ? (hook.getFiberRoots(entry[0]) || []) : []);
|
|
214
|
-
if (r.length > 0) { roots = r; break; }
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
if (roots.length === 0) return { error: 'No fiber roots found.' };
|
|
218
|
-
|
|
219
|
-
function getMeasurable(fiber) {
|
|
220
|
-
var sn = fiber.stateNode;
|
|
221
|
-
if (!sn) return null;
|
|
222
|
-
if (typeof sn.measureInWindow === 'function') return sn;
|
|
223
|
-
if (sn.canonical && sn.canonical.publicInstance &&
|
|
224
|
-
typeof sn.canonical.publicInstance.measureInWindow === 'function') {
|
|
225
|
-
return sn.canonical.publicInstance;
|
|
226
|
-
}
|
|
227
|
-
// Fabric leaf nodes like RCTText have no publicInstance. Measure via
|
|
228
|
-
// the native Fabric UIManager using the shadow node instead — needed
|
|
229
|
-
// for text bounds that are tight around the glyphs (not the scroll
|
|
230
|
-
// container the text happens to live inside).
|
|
231
|
-
if (sn.node && globalThis.nativeFabricUIManager &&
|
|
232
|
-
typeof globalThis.nativeFabricUIManager.measureInWindow === 'function') {
|
|
233
|
-
var node = sn.node;
|
|
234
|
-
return {
|
|
235
|
-
measureInWindow: function(cb) {
|
|
236
|
-
try { globalThis.nativeFabricUIManager.measureInWindow(node, cb); } catch(e) {}
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function findFirstHost(fiber, depth) {
|
|
244
|
-
if (!fiber || depth > 20) return null;
|
|
245
|
-
if (typeof fiber.type === 'string' && getMeasurable(fiber)) return fiber;
|
|
246
|
-
var child = fiber.child;
|
|
247
|
-
while (child) {
|
|
248
|
-
var found = findFirstHost(child, depth + 1);
|
|
249
|
-
if (found) return found;
|
|
250
|
-
child = child.sibling;
|
|
251
|
-
}
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function findHostsInSubtree(fiber, depth, hosts, limit) {
|
|
256
|
-
if (!fiber || depth > 20 || hosts.length >= limit) return;
|
|
257
|
-
if (typeof fiber.type === 'string' && getMeasurable(fiber)) {
|
|
258
|
-
hosts.push(fiber);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
var child = fiber.child;
|
|
262
|
-
while (child && hosts.length < limit) {
|
|
263
|
-
findHostsInSubtree(child, depth + 1, hosts, limit);
|
|
264
|
-
child = child.sibling;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function collectText(fiber, d, isRoot) {
|
|
269
|
-
if (!fiber || d > 30) return '';
|
|
270
|
-
var props = fiber.memoizedProps;
|
|
271
|
-
// Stop descent at nested pressable/input boundaries — their text belongs to them, not to the outer wrapper.
|
|
272
|
-
if (!isRoot && props && (typeof props.onPress === 'function' ||
|
|
273
|
-
typeof props.onChangeText === 'function' ||
|
|
274
|
-
typeof props.onFocus === 'function')) {
|
|
275
|
-
return '';
|
|
276
|
-
}
|
|
277
|
-
// Host text fibers carry the raw string as memoizedProps — needed when a
|
|
278
|
-
// mixed-children Text falls through to the fiber-child walk below.
|
|
279
|
-
if (typeof props === 'string') return props;
|
|
280
|
-
if (typeof props === 'number') return String(props);
|
|
281
|
-
if (props) {
|
|
282
|
-
var ch = props.children;
|
|
283
|
-
if (typeof ch === 'string') return ch;
|
|
284
|
-
if (typeof ch === 'number') return String(ch);
|
|
285
|
-
if (Array.isArray(ch)) {
|
|
286
|
-
var inline = [];
|
|
287
|
-
var hasElement = false;
|
|
288
|
-
for (var ci = 0; ci < ch.length; ci++) {
|
|
289
|
-
if (typeof ch[ci] === 'string') inline.push(ch[ci]);
|
|
290
|
-
else if (typeof ch[ci] === 'number') inline.push(String(ch[ci]));
|
|
291
|
-
else if (ch[ci] != null && ch[ci] !== false) hasElement = true;
|
|
292
|
-
}
|
|
293
|
-
// Mixed content ("Create " + <Text>2 digital items</Text>): fall through
|
|
294
|
-
// to the fiber-child walk so nested Text elements are included; the host
|
|
295
|
-
// text fibers re-supply the inline strings, so nothing is lost or doubled.
|
|
296
|
-
if (inline.length > 0 && !hasElement) return inline.join('');
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
var parts = [];
|
|
300
|
-
var child = fiber.child;
|
|
301
|
-
while (child) {
|
|
302
|
-
var t = collectText(child, d + 1, false);
|
|
303
|
-
if (t) parts.push(t);
|
|
304
|
-
child = child.sibling;
|
|
305
|
-
}
|
|
306
|
-
return parts.join(' ').replace(/\\s+/g, ' ').trim();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function getComponentName(fiber) {
|
|
310
|
-
if (!fiber || !fiber.type) return null;
|
|
311
|
-
if (typeof fiber.type === 'string') return fiber.type;
|
|
312
|
-
return fiber.type.displayName || fiber.type.name || null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
${VISIBILITY_HELPERS_JS}
|
|
316
|
-
|
|
317
|
-
var RN_PRIMITIVES = /^(Animated\\(.*|withAnimated.*|AnimatedComponent.*|ForwardRef.*|memo\\(.*|Context\\.Consumer|Context\\.Provider|ScrollViewContext(Base)?|VirtualizedListContext(Resetter)?|TextInputContext|KeyboardAvoidingViewContext|RCT.*|RNS.*|RNC.*|ViewManagerAdapter_.*|VirtualizedList.*|CellRenderer.*|FrameSizeProvider.*|MaybeScreenContainer|MaybeScreen|Navigation.*|Screen$|ScreenStack|ScreenContainer|ScreenContentWrapper|SceneView|DelayedFreeze|Freeze|Suspender|DebugContainer|StaticContainer|SafeAreaProvider.*|SafeAreaFrameContext|SafeAreaInsetsContext|ExpoRoot|ExpoRootComponent|GestureHandler.*|NativeViewGestureHandler|GestureDetector|PanGestureHandler|Reanimated.*|BottomTabNavigator|TabLayout|RouteNode|Route$|KeyboardProvider|PortalProviderComponent|BottomSheetModalProviderWrapper|ThemeContext|ThemeProvider|TextAncestorContext|PressabilityDebugView|TouchableHighlightImpl|StatusBarOverlay|BottomSheetHostingContainerComponent|BottomSheetGestureHandlersProvider|BottomSheetBackdropContainerComponent|BottomSheetContainerComponent|BottomSheetDraggableViewComponent|BottomSheetHandleContainerComponent|BottomSheetBackgroundContainerComponent|DebuggingOverlay|InspectorDeferred|Inspector|InspectorOverlay|InspectorPanel|StyleInspector|BoxInspector|BoxContainer|ElementBox|BorderBox|InspectorPanelButton)$/;
|
|
318
|
-
|
|
319
|
-
var hostFibers = [];
|
|
320
|
-
var fiberMeta = [];
|
|
321
|
-
var textFibers = [];
|
|
322
|
-
var textContents = [];
|
|
323
|
-
|
|
324
|
-
function findMeaningfulAncestorName(fiber) {
|
|
325
|
-
var cur = fiber.return;
|
|
326
|
-
var depth = 0;
|
|
327
|
-
var fallbackName = null;
|
|
328
|
-
while (cur && depth < 20) {
|
|
329
|
-
var name = getComponentName(cur);
|
|
330
|
-
if (name && typeof cur.type !== 'string') {
|
|
331
|
-
if (!fallbackName) fallbackName = name;
|
|
332
|
-
if (!RN_PRIMITIVES.test(name)) return name;
|
|
333
|
-
}
|
|
334
|
-
cur = cur.return;
|
|
335
|
-
depth++;
|
|
336
|
-
}
|
|
337
|
-
return fallbackName;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Layout-only and touch-wrapper components that should be skipped when scanning
|
|
341
|
-
// children for a meaningful icon/content component name.
|
|
342
|
-
var SKIP_IN_CHILD_SCAN = /^(View|Text|Image|ImageBackground|ScrollView|FlatList|SectionList|KeyboardAvoidingView|SafeAreaView|TouchableOpacity|TouchableHighlight|TouchableWithoutFeedback|TouchableNativeFeedback|Pressable|TextInput|ActivityIndicator|Switch|Modal|StatusBar|VirtualizedList|RefreshControl|Animated\\(.*|withAnimated.*|AnimatedComponent.*)$/;
|
|
343
|
-
|
|
344
|
-
// Scan children of a pressable for a meaningful component name (e.g. SvgChevronBack inside a View).
|
|
345
|
-
// Skips generic layout/touch wrappers to find the actual icon or content component.
|
|
346
|
-
function findMeaningfulChildName(fiber) {
|
|
347
|
-
function scan(f, d) {
|
|
348
|
-
if (!f || d > 12) return null;
|
|
349
|
-
var n = getComponentName(f);
|
|
350
|
-
if (n && typeof f.type !== 'string' && !RN_PRIMITIVES.test(n) && !SKIP_IN_CHILD_SCAN.test(n)) return n;
|
|
351
|
-
var c = f.child;
|
|
352
|
-
while (c) {
|
|
353
|
-
var r = scan(c, d + 1);
|
|
354
|
-
if (r) return r;
|
|
355
|
-
c = c.sibling;
|
|
356
|
-
}
|
|
357
|
-
return null;
|
|
358
|
-
}
|
|
359
|
-
return scan(fiber.child, 0);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
var GENERIC_COMPONENT = /^(View|TouchableOpacity|TouchableHighlight|TouchableWithoutFeedback|Pressable|TouchableNativeFeedback|Text|RCTView|RCTText|Unknown)$/;
|
|
363
|
-
|
|
364
|
-
// The components that render PressabilityDebugView in DEV builds. Used to walk
|
|
365
|
-
// up from a PDV's host view to the touchable that owns it — the composite chain
|
|
366
|
-
// in between is generic wrappers (View ForwardRef, Animated(View)) whose props
|
|
367
|
-
// carry no onPress, so they are useless as a collectText / identifier root.
|
|
368
|
-
var PDV_OWNER_COMPONENT = /^(Pressable|Touchable(Opacity|Highlight|WithoutFeedback|NativeFeedback|Bounce))$/;
|
|
369
|
-
|
|
370
|
-
function buildPath(fiber) {
|
|
371
|
-
var parts = [];
|
|
372
|
-
var cur = fiber;
|
|
373
|
-
var depth = 0;
|
|
374
|
-
while (cur && depth < 30) {
|
|
375
|
-
var name = getComponentName(cur);
|
|
376
|
-
if (name && typeof cur.type !== 'string' && !RN_PRIMITIVES.test(name)) {
|
|
377
|
-
parts.unshift(name);
|
|
378
|
-
}
|
|
379
|
-
cur = cur.return;
|
|
380
|
-
depth++;
|
|
381
|
-
}
|
|
382
|
-
// Keep last 3 segments
|
|
383
|
-
if (parts.length > 3) {
|
|
384
|
-
parts = parts.slice(-3);
|
|
385
|
-
return '... > ' + parts.join(' > ');
|
|
386
|
-
}
|
|
387
|
-
return parts.join(' > ');
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function walkPressables(fiber, depth) {
|
|
391
|
-
if (!fiber || depth > 5000) return;
|
|
392
|
-
var name = getComponentName(fiber);
|
|
393
|
-
var props = fiber.memoizedProps;
|
|
394
|
-
|
|
395
|
-
// Skip inactive/unfocused screens
|
|
396
|
-
if (isHiddenNavigationScene(name, props)) return;
|
|
397
|
-
|
|
398
|
-
var isPressable = props && typeof props.onPress === 'function';
|
|
399
|
-
var isInput = !isPressable && props && (typeof props.onChangeText === 'function' || typeof props.onFocus === 'function');
|
|
400
|
-
|
|
401
|
-
if (isPressable || isInput) {
|
|
402
|
-
var hostsForThis = [];
|
|
403
|
-
findHostsInSubtree(fiber, 0, hostsForThis, 16);
|
|
404
|
-
if (hostsForThis.length > 0) {
|
|
405
|
-
var text = collectText(fiber, 0, true);
|
|
406
|
-
var componentName = findMeaningfulAncestorName(fiber) || name || 'Unknown';
|
|
407
|
-
// Scan children for a meaningful name like SvgChevronBack: it replaces a
|
|
408
|
-
// generic name (View, TouchableOpacity) and is also kept as the icon hint
|
|
409
|
-
// for text-less buttons even when the ancestor name (FloatingHeader) wins.
|
|
410
|
-
var childName = (!text || GENERIC_COMPONENT.test(componentName)) ? findMeaningfulChildName(fiber) : null;
|
|
411
|
-
if (childName && GENERIC_COMPONENT.test(componentName)) componentName = childName;
|
|
412
|
-
var path = buildPath(fiber);
|
|
413
|
-
var testID = (props && (props.testID || props.nativeID)) || null;
|
|
414
|
-
var accessibilityLabel = (props && props.accessibilityLabel) || null;
|
|
415
|
-
|
|
416
|
-
var hostIndices = [];
|
|
417
|
-
for (var hi = 0; hi < hostsForThis.length; hi++) {
|
|
418
|
-
hostIndices.push(hostFibers.length);
|
|
419
|
-
hostFibers.push(hostsForThis[hi]);
|
|
420
|
-
}
|
|
421
|
-
fiberMeta.push({
|
|
422
|
-
component: componentName,
|
|
423
|
-
path: path,
|
|
424
|
-
text: text ? text.slice(0, 100) : '',
|
|
425
|
-
testID: testID,
|
|
426
|
-
accessibilityLabel: accessibilityLabel,
|
|
427
|
-
isInput: !!isInput,
|
|
428
|
-
icon: childName || null,
|
|
429
|
-
hostIndices: hostIndices
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
var child = fiber.child;
|
|
435
|
-
while (child) {
|
|
436
|
-
walkPressables(child, depth + 1);
|
|
437
|
-
child = child.sibling;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// PRIMARY detection: PressabilityDebugView-based walk — mirrors RN Inspector "Touchables" exactly.
|
|
442
|
-
// In DEV mode, every Pressable / TouchableOpacity / TouchableHighlight /
|
|
443
|
-
// TouchableWithoutFeedback / TouchableNativeFeedback renders a PressabilityDebugView
|
|
444
|
-
// as a child. Its immediate parent (fiber.return) is the HOST view that carries
|
|
445
|
-
// the event handlers — we measure that.
|
|
446
|
-
// FALLBACK: plain onPress detection for production builds (PDV not rendered).
|
|
447
|
-
|
|
448
|
-
var anyPDV = false;
|
|
449
|
-
(function quickCheckPDV(f, d) {
|
|
450
|
-
if (!f || d > 200 || anyPDV) return;
|
|
451
|
-
var n = getComponentName(f);
|
|
452
|
-
if (n === 'PressabilityDebugView') { anyPDV = true; return; }
|
|
453
|
-
quickCheckPDV(f.child, d + 1);
|
|
454
|
-
if (!anyPDV) quickCheckPDV(f.sibling, d);
|
|
455
|
-
})(roots[0].current, 0);
|
|
456
|
-
|
|
457
|
-
// Walk for TextInput elements (not covered by PressabilityDebugView in either mode).
|
|
458
|
-
function walkInputs(fiber, depth) {
|
|
459
|
-
if (!fiber || depth > 5000) return;
|
|
460
|
-
var name = getComponentName(fiber);
|
|
461
|
-
var props = fiber.memoizedProps;
|
|
462
|
-
if (isHiddenNavigationScene(name, props)) return;
|
|
463
|
-
var isInput = props && !props.onPress &&
|
|
464
|
-
(typeof props.onChangeText === 'function' || typeof props.onFocus === 'function');
|
|
465
|
-
if (isInput) {
|
|
466
|
-
var hostsForThis = [];
|
|
467
|
-
findHostsInSubtree(fiber, 0, hostsForThis, 16);
|
|
468
|
-
if (hostsForThis.length > 0) {
|
|
469
|
-
var text = collectText(fiber, 0, true);
|
|
470
|
-
var componentName = findMeaningfulAncestorName(fiber) || name || 'Unknown';
|
|
471
|
-
if (GENERIC_COMPONENT.test(componentName)) {
|
|
472
|
-
var childName = findMeaningfulChildName(fiber);
|
|
473
|
-
if (childName) componentName = childName;
|
|
474
|
-
}
|
|
475
|
-
var props2 = fiber.memoizedProps || {};
|
|
476
|
-
var testID = (props2.testID || props2.nativeID) || null;
|
|
477
|
-
var accessibilityLabel = props2.accessibilityLabel || null;
|
|
478
|
-
var hostIndices = [];
|
|
479
|
-
for (var hi3 = 0; hi3 < hostsForThis.length; hi3++) {
|
|
480
|
-
hostIndices.push(hostFibers.length);
|
|
481
|
-
hostFibers.push(hostsForThis[hi3]);
|
|
482
|
-
}
|
|
483
|
-
fiberMeta.push({
|
|
484
|
-
component: componentName,
|
|
485
|
-
path: buildPath(fiber),
|
|
486
|
-
text: text ? text.slice(0, 100) : '',
|
|
487
|
-
testID: testID,
|
|
488
|
-
accessibilityLabel: accessibilityLabel,
|
|
489
|
-
isInput: true,
|
|
490
|
-
hostIndices: hostIndices
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
var child = fiber.child;
|
|
495
|
-
while (child) { walkInputs(child, depth + 1); child = child.sibling; }
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function walkPressabilityDebugViews(fiber, depth, hidden) {
|
|
499
|
-
if (!fiber || depth > 5000) return;
|
|
500
|
-
var name = getComponentName(fiber);
|
|
501
|
-
var props = fiber.memoizedProps;
|
|
502
|
-
var nextHidden = hidden;
|
|
503
|
-
if (isHiddenNavigationScene(name, props)) nextHidden = true;
|
|
504
|
-
|
|
505
|
-
if (!nextHidden && name === 'PressabilityDebugView') {
|
|
506
|
-
// fiber.return = the HOST view that wraps the pressable's children
|
|
507
|
-
var hostFiber = fiber.return;
|
|
508
|
-
if (hostFiber && getMeasurable(hostFiber)) {
|
|
509
|
-
// Walk up from the host fiber to the touchable component that owns this
|
|
510
|
-
// PDV. Match by name (PDV_OWNER_COMPONENT) — stopping at the first
|
|
511
|
-
// composite ancestor is wrong because that's a generic wrapper
|
|
512
|
-
// (ForwardRef displayName 'View', then Animated(View) for Touchables).
|
|
513
|
-
var pressableFiber = hostFiber;
|
|
514
|
-
var firstComposite = null;
|
|
515
|
-
var cur = hostFiber.return;
|
|
516
|
-
var upDepth = 0;
|
|
517
|
-
while (cur && upDepth < 10) {
|
|
518
|
-
if (typeof cur.type !== 'string' && cur.type !== null) {
|
|
519
|
-
if (!firstComposite) firstComposite = cur;
|
|
520
|
-
var curName = getComponentName(cur);
|
|
521
|
-
if (curName && PDV_OWNER_COMPONENT.test(curName)) {
|
|
522
|
-
pressableFiber = cur;
|
|
523
|
-
break;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
cur = cur.return;
|
|
527
|
-
upDepth++;
|
|
528
|
-
}
|
|
529
|
-
if (pressableFiber === hostFiber && firstComposite) pressableFiber = firstComposite;
|
|
530
|
-
|
|
531
|
-
var pressableProps = pressableFiber.memoizedProps || {};
|
|
532
|
-
var text = collectText(pressableFiber, 0, true);
|
|
533
|
-
// Prefer the nearest semantic ancestor (FilterRow, RoleButton, ...) over
|
|
534
|
-
// generic Touchable/Pressable/View wrappers. The climb is capped at a few
|
|
535
|
-
// composite levels so a bare Pressable isn't named after its screen container.
|
|
536
|
-
var componentName = null;
|
|
537
|
-
var an = pressableFiber.return;
|
|
538
|
-
var anComposites = 0;
|
|
539
|
-
var anDepth = 0;
|
|
540
|
-
while (an && anDepth < 12 && anComposites < 4 && !componentName) {
|
|
541
|
-
if (typeof an.type !== 'string') {
|
|
542
|
-
var anName = getComponentName(an);
|
|
543
|
-
if (anName) {
|
|
544
|
-
anComposites++;
|
|
545
|
-
if (!RN_PRIMITIVES.test(anName) && !GENERIC_COMPONENT.test(anName)) {
|
|
546
|
-
componentName = anName;
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
an = an.return;
|
|
551
|
-
anDepth++;
|
|
552
|
-
}
|
|
553
|
-
if (!componentName) {
|
|
554
|
-
componentName = findMeaningfulAncestorName(pressableFiber) || getComponentName(pressableFiber) || 'Unknown';
|
|
555
|
-
}
|
|
556
|
-
// Keep the icon child name even when the ancestor name wins — icon-only
|
|
557
|
-
// buttons get their semantics from it (SvgChevronBack → back button).
|
|
558
|
-
var childName = (!text || GENERIC_COMPONENT.test(componentName)) ? findMeaningfulChildName(pressableFiber) : null;
|
|
559
|
-
if (childName && GENERIC_COMPONENT.test(componentName)) componentName = childName;
|
|
560
|
-
var path = buildPath(pressableFiber);
|
|
561
|
-
|
|
562
|
-
// testID/accessibilityLabel are spread onto the host view by TouchableOpacity etc.
|
|
563
|
-
var hostProps = hostFiber.memoizedProps || {};
|
|
564
|
-
var testID = hostProps.testID || hostProps.nativeID || pressableProps.testID || pressableProps.nativeID || null;
|
|
565
|
-
var accessibilityLabel = hostProps.accessibilityLabel || pressableProps.accessibilityLabel || null;
|
|
566
|
-
|
|
567
|
-
var hostIdx = hostFibers.length;
|
|
568
|
-
hostFibers.push(hostFiber);
|
|
569
|
-
fiberMeta.push({
|
|
570
|
-
component: componentName,
|
|
571
|
-
path: path,
|
|
572
|
-
text: text ? text.slice(0, 100) : '',
|
|
573
|
-
testID: testID,
|
|
574
|
-
accessibilityLabel: accessibilityLabel,
|
|
575
|
-
isInput: false,
|
|
576
|
-
icon: childName || null,
|
|
577
|
-
hostIndices: [hostIdx]
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
// Do not recurse into PressabilityDebugView's own children (debug overlay)
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
var child = fiber.child;
|
|
585
|
-
while (child) {
|
|
586
|
-
walkPressabilityDebugViews(child, depth + 1, nextHidden);
|
|
587
|
-
child = child.sibling;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
if (anyPDV) {
|
|
592
|
-
walkPressabilityDebugViews(roots[0].current, 0, false);
|
|
593
|
-
walkInputs(roots[0].current, 0);
|
|
594
|
-
} else {
|
|
595
|
-
// Fallback: original onPress-based detection for production builds
|
|
596
|
-
walkPressables(roots[0].current, 0);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Collect text fibers that are NOT inside a pressable — used later to attach
|
|
600
|
-
// spatial 'nearbyText' hints to icon-only pressables.
|
|
601
|
-
function extractTextString(fiber) {
|
|
602
|
-
var p = fiber.memoizedProps;
|
|
603
|
-
if (!p) return '';
|
|
604
|
-
var ch = p.children;
|
|
605
|
-
if (typeof ch === 'string') return ch;
|
|
606
|
-
if (typeof ch === 'number') return String(ch);
|
|
607
|
-
if (Array.isArray(ch)) {
|
|
608
|
-
var parts = [];
|
|
609
|
-
for (var k = 0; k < ch.length; k++) {
|
|
610
|
-
if (typeof ch[k] === 'string') parts.push(ch[k]);
|
|
611
|
-
else if (typeof ch[k] === 'number') parts.push(String(ch[k]));
|
|
612
|
-
}
|
|
613
|
-
if (parts.length > 0) return parts.join('');
|
|
614
|
-
}
|
|
615
|
-
return '';
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function walkTexts(fiber, depth, insidePressable, inHidden) {
|
|
619
|
-
if (!fiber || depth > 5000) return;
|
|
620
|
-
var name = getComponentName(fiber);
|
|
621
|
-
var props = fiber.memoizedProps;
|
|
622
|
-
|
|
623
|
-
// Track hidden-screen ancestry without early return — a MaybeScreen wrapper
|
|
624
|
-
// can enclose BOTH the active and inactive siblings, so we must keep walking
|
|
625
|
-
// to find the active one while still skipping the inactive subtree's texts.
|
|
626
|
-
var nextHidden = inHidden;
|
|
627
|
-
if (isHiddenNavigationScene(name, props)) nextHidden = true;
|
|
628
|
-
|
|
629
|
-
var hasOnPress = props && typeof props.onPress === 'function';
|
|
630
|
-
var isInputHere = props && (typeof props.onChangeText === 'function' || typeof props.onFocus === 'function');
|
|
631
|
-
var nextInside = insidePressable || !!hasOnPress || !!isInputHere;
|
|
632
|
-
|
|
633
|
-
// Record standalone text when outside any pressable. Detects both stock
|
|
634
|
-
// Text and custom wrappers (CustomText, ThemedText, etc.) by checking for
|
|
635
|
-
// direct string/number children. Fabric RCTText has no publicInstance, so
|
|
636
|
-
// we walk up the fiber chain to the nearest measurable host (the View
|
|
637
|
-
// enclosing the text) and use its frame as the text's proxy bounds.
|
|
638
|
-
if (!insidePressable && !nextHidden && name !== 'RCTText' && typeof fiber.type !== 'string') {
|
|
639
|
-
var str = extractTextString(fiber);
|
|
640
|
-
if (str && str.length > 0 && str.length <= 120) {
|
|
641
|
-
var up = fiber;
|
|
642
|
-
var upDepth = 0;
|
|
643
|
-
var measurable = null;
|
|
644
|
-
while (up && upDepth < 20) {
|
|
645
|
-
if (typeof up.type === 'string' && getMeasurable(up)) {
|
|
646
|
-
measurable = up;
|
|
647
|
-
break;
|
|
648
|
-
}
|
|
649
|
-
up = up.return;
|
|
650
|
-
upDepth++;
|
|
651
|
-
}
|
|
652
|
-
if (measurable) {
|
|
653
|
-
textFibers.push(measurable);
|
|
654
|
-
textContents.push(str);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
var child = fiber.child;
|
|
660
|
-
while (child) {
|
|
661
|
-
walkTexts(child, depth + 1, nextInside, nextHidden);
|
|
662
|
-
child = child.sibling;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
walkTexts(roots[0].current, 0, false, false);
|
|
666
|
-
|
|
667
|
-
if (hostFibers.length === 0) return { error: 'No pressable elements found on screen.' };
|
|
668
|
-
|
|
669
|
-
// Also measure the root view for viewport detection (appended; tracked by explicit index to preserve hostIndices)
|
|
670
|
-
var rootHost = findFirstHost(roots[0].current, 0);
|
|
671
|
-
var rootIdx = -1;
|
|
672
|
-
if (rootHost) {
|
|
673
|
-
rootIdx = hostFibers.length;
|
|
674
|
-
hostFibers.push(rootHost);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
globalThis.__pressableFibers = hostFibers;
|
|
678
|
-
globalThis.__pressableMeta = fiberMeta;
|
|
679
|
-
globalThis.__pressableMeasurements = new Array(hostFibers.length).fill(null);
|
|
680
|
-
globalThis.__pressableRootIdx = rootIdx;
|
|
681
|
-
|
|
682
|
-
for (var i = 0; i < hostFibers.length; i++) {
|
|
683
|
-
try {
|
|
684
|
-
(function(idx) {
|
|
685
|
-
getMeasurable(hostFibers[idx]).measureInWindow(function(fx, fy, fw, fh) {
|
|
686
|
-
globalThis.__pressableMeasurements[idx] = { x: fx, y: fy, width: fw, height: fh };
|
|
687
|
-
});
|
|
688
|
-
})(i);
|
|
689
|
-
} catch(e) {}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
globalThis.__pressableTextContents = textContents;
|
|
693
|
-
globalThis.__pressableTextMeasurements = new Array(textFibers.length).fill(null);
|
|
694
|
-
for (var ti = 0; ti < textFibers.length; ti++) {
|
|
695
|
-
try {
|
|
696
|
-
(function(idx) {
|
|
697
|
-
getMeasurable(textFibers[idx]).measureInWindow(function(fx, fy, fw, fh) {
|
|
698
|
-
globalThis.__pressableTextMeasurements[idx] = { x: fx, y: fy, width: fw, height: fh };
|
|
699
|
-
});
|
|
700
|
-
})(ti);
|
|
701
|
-
} catch(e) {}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
return { count: hostFibers.length, textCount: textFibers.length };
|
|
705
|
-
})()
|
|
706
|
-
`;
|
|
707
|
-
const dispatchResult = await executeInApp(dispatchExpression, false, { timeoutMs: 30000, originatingToolName: "get_pressable_elements" }, device);
|
|
708
|
-
if (!dispatchResult.success)
|
|
709
|
-
return dispatchResult;
|
|
710
|
-
try {
|
|
711
|
-
const parsed = JSON.parse(dispatchResult.result || "{}");
|
|
712
|
-
if (parsed.error)
|
|
713
|
-
return { success: false, error: parsed.error };
|
|
714
|
-
}
|
|
715
|
-
catch {
|
|
716
|
-
/* ignore */
|
|
717
|
-
}
|
|
718
|
-
// Wait for measureInWindow callbacks (adaptive; see waitForMeasurements)
|
|
719
|
-
await awaitMeasurements(["__pressableMeasurements", "__pressableTextMeasurements"], "get_pressable_elements", device);
|
|
720
|
-
// --- Step 2: read measurements, filter visible, build results ---
|
|
721
|
-
const resolveExpression = `
|
|
722
|
-
(function() {
|
|
723
|
-
var fibers = globalThis.__pressableFibers;
|
|
724
|
-
var meta = globalThis.__pressableMeta;
|
|
725
|
-
var measurements = globalThis.__pressableMeasurements;
|
|
726
|
-
var rootIdx = globalThis.__pressableRootIdx;
|
|
727
|
-
var textContents = globalThis.__pressableTextContents || [];
|
|
728
|
-
var textMeasurements = globalThis.__pressableTextMeasurements || [];
|
|
729
|
-
globalThis.__pressableFibers = null;
|
|
730
|
-
globalThis.__pressableMeta = null;
|
|
731
|
-
globalThis.__pressableMeasurements = null;
|
|
732
|
-
globalThis.__pressableRootIdx = null;
|
|
733
|
-
globalThis.__pressableTextContents = null;
|
|
734
|
-
globalThis.__pressableTextMeasurements = null;
|
|
735
|
-
|
|
736
|
-
if (!fibers || !measurements || !meta) {
|
|
737
|
-
return { error: 'No measurement data. Run get_pressable_elements again.' };
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Get viewport dimensions from the explicit root measurement (fallback to scanning)
|
|
741
|
-
var viewportW = 9999, viewportH = 9999;
|
|
742
|
-
var rootM = (rootIdx != null && rootIdx >= 0) ? measurements[rootIdx] : null;
|
|
743
|
-
if (rootM && rootM.width > 0 && rootM.height > 0) {
|
|
744
|
-
viewportW = rootM.width;
|
|
745
|
-
viewportH = rootM.height + (rootM.y > 0 ? rootM.y : 0);
|
|
746
|
-
} else {
|
|
747
|
-
for (var v = 0; v < measurements.length; v++) {
|
|
748
|
-
if (measurements[v] && measurements[v].x === 0 && measurements[v].y <= 0 &&
|
|
749
|
-
measurements[v].width > 0 && measurements[v].height > 0) {
|
|
750
|
-
viewportW = measurements[v].width;
|
|
751
|
-
viewportH = measurements[v].height + measurements[v].y;
|
|
752
|
-
break;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
var elements = [];
|
|
758
|
-
|
|
759
|
-
for (var i = 0; i < meta.length; i++) {
|
|
760
|
-
var info = meta[i];
|
|
761
|
-
|
|
762
|
-
// Union all host measurements for this pressable to get its true bounds
|
|
763
|
-
var uMinX = Infinity, uMinY = Infinity, uMaxX = -Infinity, uMaxY = -Infinity;
|
|
764
|
-
var hasValid = false;
|
|
765
|
-
var indices = info.hostIndices || [];
|
|
766
|
-
for (var hi2 = 0; hi2 < indices.length; hi2++) {
|
|
767
|
-
var mm = measurements[indices[hi2]];
|
|
768
|
-
if (!mm || mm.width <= 0 || mm.height <= 0) continue;
|
|
769
|
-
hasValid = true;
|
|
770
|
-
if (mm.x < uMinX) uMinX = mm.x;
|
|
771
|
-
if (mm.y < uMinY) uMinY = mm.y;
|
|
772
|
-
if (mm.x + mm.width > uMaxX) uMaxX = mm.x + mm.width;
|
|
773
|
-
if (mm.y + mm.height > uMaxY) uMaxY = mm.y + mm.height;
|
|
774
|
-
}
|
|
775
|
-
if (!hasValid) continue;
|
|
776
|
-
var m = { x: uMinX, y: uMinY, width: uMaxX - uMinX, height: uMaxY - uMinY };
|
|
777
|
-
|
|
778
|
-
// Filter: only visible within viewport
|
|
779
|
-
if (m.width <= 0 || m.height <= 0) continue;
|
|
780
|
-
if (m.x + m.width < 0 || m.y + m.height < 0) continue;
|
|
781
|
-
if (m.x > viewportW || m.y > viewportH) continue;
|
|
782
|
-
|
|
783
|
-
var text = info.text || '';
|
|
784
|
-
|
|
785
|
-
elements.push({
|
|
786
|
-
component: info.component,
|
|
787
|
-
path: info.path,
|
|
788
|
-
center: {
|
|
789
|
-
x: Math.round(m.x + m.width / 2),
|
|
790
|
-
y: Math.round(m.y + m.height / 2)
|
|
791
|
-
},
|
|
792
|
-
frame: {
|
|
793
|
-
x: Math.round(m.x),
|
|
794
|
-
y: Math.round(m.y),
|
|
795
|
-
width: Math.round(m.width),
|
|
796
|
-
height: Math.round(m.height)
|
|
797
|
-
},
|
|
798
|
-
text: text,
|
|
799
|
-
testID: info.testID,
|
|
800
|
-
accessibilityLabel: info.accessibilityLabel,
|
|
801
|
-
hasLabel: text.length > 0,
|
|
802
|
-
isInput: info.isInput,
|
|
803
|
-
icon: info.icon || null
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Deduplicate: multiple nested pressables (View > TouchableOpacity > TouchableOpacity)
|
|
808
|
-
// often share the same frame. Keep the one with the most meaningful component name,
|
|
809
|
-
// but merge text/testID/accessibilityLabel from the loser so stopping collectText
|
|
810
|
-
// at nested-pressable boundaries does not drop labels across the merge.
|
|
811
|
-
var HOST_NAMES = /^(View|TouchableOpacity|TouchableHighlight|TouchableWithoutFeedback|Pressable|TouchableNativeFeedback|Text|RCTView|RCTText)$/;
|
|
812
|
-
function mergeFields(winner, loser) {
|
|
813
|
-
if (!winner.text && loser.text) {
|
|
814
|
-
winner.text = loser.text;
|
|
815
|
-
winner.hasLabel = winner.text.length > 0;
|
|
816
|
-
}
|
|
817
|
-
if (!winner.testID && loser.testID) winner.testID = loser.testID;
|
|
818
|
-
if (!winner.accessibilityLabel && loser.accessibilityLabel) winner.accessibilityLabel = loser.accessibilityLabel;
|
|
819
|
-
if (!winner.icon && loser.icon) winner.icon = loser.icon;
|
|
820
|
-
return winner;
|
|
821
|
-
}
|
|
822
|
-
var deduped = {};
|
|
823
|
-
for (var di = 0; di < elements.length; di++) {
|
|
824
|
-
var el = elements[di];
|
|
825
|
-
var key = el.frame.x + ',' + el.frame.y + ',' + el.frame.width + ',' + el.frame.height;
|
|
826
|
-
var existing = deduped[key];
|
|
827
|
-
if (!existing) {
|
|
828
|
-
deduped[key] = el;
|
|
829
|
-
} else {
|
|
830
|
-
var existingIsGeneric = HOST_NAMES.test(existing.component);
|
|
831
|
-
var newIsGeneric = HOST_NAMES.test(el.component);
|
|
832
|
-
var winner, loser;
|
|
833
|
-
if (existingIsGeneric && !newIsGeneric) {
|
|
834
|
-
winner = el; loser = existing;
|
|
835
|
-
} else if (!existingIsGeneric && newIsGeneric) {
|
|
836
|
-
winner = existing; loser = el;
|
|
837
|
-
} else {
|
|
838
|
-
// Both generic or both meaningful — prefer the one with more identifiers.
|
|
839
|
-
if (!existing.testID && el.testID) { winner = el; loser = existing; }
|
|
840
|
-
else if (!existing.accessibilityLabel && el.accessibilityLabel) { winner = el; loser = existing; }
|
|
841
|
-
else { winner = existing; loser = el; }
|
|
842
|
-
}
|
|
843
|
-
deduped[key] = mergeFields(winner, loser);
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
elements = [];
|
|
847
|
-
for (var dk in deduped) {
|
|
848
|
-
elements.push(deduped[dk]);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// Tag wrappers: pressables that cover >=50% of viewport AND geometrically contain another pressable.
|
|
852
|
-
// These are typically keyboard-dismiss/full-screen Touchable wrappers — agents should skip them.
|
|
853
|
-
var viewportArea = (viewportW > 0 && viewportH > 0 && viewportW < 9999 && viewportH < 9999)
|
|
854
|
-
? viewportW * viewportH : 0;
|
|
855
|
-
if (viewportArea > 0) {
|
|
856
|
-
for (var wi = 0; wi < elements.length; wi++) {
|
|
857
|
-
var we = elements[wi];
|
|
858
|
-
var weArea = we.frame.width * we.frame.height;
|
|
859
|
-
if (weArea < viewportArea * 0.5) continue;
|
|
860
|
-
for (var wj = 0; wj < elements.length; wj++) {
|
|
861
|
-
if (wj === wi) continue;
|
|
862
|
-
var other = elements[wj];
|
|
863
|
-
if (other.frame.x >= we.frame.x &&
|
|
864
|
-
other.frame.y >= we.frame.y &&
|
|
865
|
-
other.frame.x + other.frame.width <= we.frame.x + we.frame.width &&
|
|
866
|
-
other.frame.y + other.frame.height <= we.frame.y + we.frame.height &&
|
|
867
|
-
other.frame.width * other.frame.height < weArea) {
|
|
868
|
-
we.isWrapper = true;
|
|
869
|
-
break;
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Build a normalized list of visible standalone texts (content + frame)
|
|
876
|
-
var textBoxes = [];
|
|
877
|
-
for (var tmi = 0; tmi < textMeasurements.length; tmi++) {
|
|
878
|
-
var tm = textMeasurements[tmi];
|
|
879
|
-
var tc = textContents[tmi];
|
|
880
|
-
if (!tm || !tc) continue;
|
|
881
|
-
if (tm.width <= 0 || tm.height <= 0) continue;
|
|
882
|
-
if (tm.x + tm.width < 0 || tm.y + tm.height < 0) continue;
|
|
883
|
-
if (tm.x > viewportW || tm.y > viewportH) continue;
|
|
884
|
-
textBoxes.push({
|
|
885
|
-
text: tc,
|
|
886
|
-
x: tm.x, y: tm.y, width: tm.width, height: tm.height,
|
|
887
|
-
cx: tm.x + tm.width / 2, cy: tm.y + tm.height / 2
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
// Humanize component name → intent (strip Svg/Icon prefixes, split camelCase)
|
|
892
|
-
function humanize(name) {
|
|
893
|
-
if (!name) return '';
|
|
894
|
-
var n = String(name);
|
|
895
|
-
n = n.replace(/^(Svg|Icon)/, '').replace(/(Svg|Icon)$/, '');
|
|
896
|
-
n = n.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
897
|
-
n = n.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
|
|
898
|
-
return n.trim().toLowerCase();
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Attach nearbyText for pressables that lack their own text/label.
|
|
902
|
-
// Nearest text by center-to-center distance, within a reasonable radius.
|
|
903
|
-
var NEARBY_RADIUS = 120; // points
|
|
904
|
-
for (var ei = 0; ei < elements.length; ei++) {
|
|
905
|
-
var pe = elements[ei];
|
|
906
|
-
pe.intent = humanize(pe.icon || pe.component);
|
|
907
|
-
// Skip when the pressable already has its own human-readable text or a11y label.
|
|
908
|
-
// testID alone doesn't suppress nearbyText — the testID may be cryptic and the
|
|
909
|
-
// surrounding visible text is still a useful handle for an agent.
|
|
910
|
-
if (pe.hasLabel || pe.accessibilityLabel) continue;
|
|
911
|
-
if (textBoxes.length === 0) continue;
|
|
912
|
-
|
|
913
|
-
var pcx = pe.frame.x + pe.frame.width / 2;
|
|
914
|
-
var pcy = pe.frame.y + pe.frame.height / 2;
|
|
915
|
-
var best = null;
|
|
916
|
-
var bestDist = Infinity;
|
|
917
|
-
for (var tbi = 0; tbi < textBoxes.length; tbi++) {
|
|
918
|
-
var tb = textBoxes[tbi];
|
|
919
|
-
// Skip texts contained inside this pressable (shouldn't happen — walkTexts excludes them — but defensive)
|
|
920
|
-
if (tb.x >= pe.frame.x && tb.y >= pe.frame.y &&
|
|
921
|
-
tb.x + tb.width <= pe.frame.x + pe.frame.width &&
|
|
922
|
-
tb.y + tb.height <= pe.frame.y + pe.frame.height) continue;
|
|
923
|
-
// Row siblings first: a checkbox's label sits in the same row but its
|
|
924
|
-
// long text's CENTER is far away, so center distance alone misses it.
|
|
925
|
-
// Row-aligned + small horizontal edge gap = strongest signal.
|
|
926
|
-
var rowAligned = Math.abs(tb.cy - pcy) <= Math.max(24, pe.frame.height / 2);
|
|
927
|
-
var gapL = pe.frame.x - (tb.x + tb.width);
|
|
928
|
-
var gapR = tb.x - (pe.frame.x + pe.frame.width);
|
|
929
|
-
var hGap = Math.max(gapL, gapR);
|
|
930
|
-
if (hGap < 0) hGap = 0;
|
|
931
|
-
var d;
|
|
932
|
-
if (rowAligned && hGap <= 80) {
|
|
933
|
-
d = hGap;
|
|
934
|
-
} else {
|
|
935
|
-
var dx = tb.cx - pcx;
|
|
936
|
-
var dy = tb.cy - pcy;
|
|
937
|
-
d = Math.sqrt(dx * dx + dy * dy);
|
|
938
|
-
if (d > NEARBY_RADIUS) continue;
|
|
939
|
-
}
|
|
940
|
-
if (d < bestDist) {
|
|
941
|
-
bestDist = d;
|
|
942
|
-
best = tb;
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
if (best) pe.nearbyText = best.text;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// Sort top-to-bottom, left-to-right
|
|
949
|
-
elements.sort(function(a, b) {
|
|
950
|
-
if (a.center.y !== b.center.y) return a.center.y - b.center.y;
|
|
951
|
-
return a.center.x - b.center.x;
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
var iconCount = 0;
|
|
955
|
-
var labeledCount = 0;
|
|
956
|
-
for (var j = 0; j < elements.length; j++) {
|
|
957
|
-
if (elements[j].hasLabel) labeledCount++;
|
|
958
|
-
else iconCount++;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
return {
|
|
962
|
-
pressableElements: elements,
|
|
963
|
-
summary: 'Found ' + elements.length + ' pressable elements (' + iconCount + ' icon-only, ' + labeledCount + ' with text labels)'
|
|
964
|
-
};
|
|
965
|
-
})()
|
|
966
|
-
`;
|
|
967
|
-
const result = await executeInApp(resolveExpression, false, { timeoutMs: 30000, originatingToolName: "get_pressable_elements" }, device);
|
|
968
|
-
if (result.success && result.result) {
|
|
969
|
-
try {
|
|
970
|
-
const parsed = JSON.parse(result.result);
|
|
971
|
-
if (parsed.error)
|
|
972
|
-
return { success: false, error: parsed.error };
|
|
973
|
-
const pressableElements = parsed.pressableElements || [];
|
|
974
|
-
// Android coordinate reconciliation (2026-05-17).
|
|
975
|
-
//
|
|
976
|
-
// The fiber walker produces rich metadata (component names, testID, intent,
|
|
977
|
-
// wrapper flags, nearbyText) but its measureInWindow output on Bridgeless/Fabric
|
|
978
|
-
// is in app-window DP space — off from the actual screen-pixel hit area by the
|
|
979
|
-
// status bar height (varies per device) AND an additional unaccounted offset
|
|
980
|
-
// we couldn't characterize across devices.
|
|
981
|
-
//
|
|
982
|
-
// uiautomator's bounds, on the other hand, are in absolute display pixels and
|
|
983
|
-
// match what tap(x, y) → adb input dispatches — they are touch-accurate.
|
|
984
|
-
//
|
|
985
|
-
// Strategy: keep fiber's metadata, but replace coords with uiautomator's bounds
|
|
986
|
-
// when we can match a fiber element to a uiautomator node by text / contentDesc /
|
|
987
|
-
// resourceId. For icon-only pressables that have no text-match candidate,
|
|
988
|
-
// fall back to fiber's DP coords scaled by density — they're imperfect but
|
|
989
|
-
// they keep the same gross layout that an agent could use as a "near this
|
|
990
|
-
// region" hint, and they're better than nothing.
|
|
991
|
-
//
|
|
992
|
-
// iOS is unaffected — fiber returns points and AXe returns points; uniform.
|
|
993
|
-
if (effectivePlatform === "android" && pressableElements.length > 0) {
|
|
994
|
-
let densityScale = 2.625;
|
|
995
|
-
let statusBarPixels = 0;
|
|
996
|
-
try {
|
|
997
|
-
const { androidGetDensity, androidGetStatusBarHeight } = await import("./android.js");
|
|
998
|
-
const [densityResult, statusBarResult] = await Promise.all([
|
|
999
|
-
androidGetDensity(),
|
|
1000
|
-
androidGetStatusBarHeight().catch(() => ({ success: false, heightPixels: 0 })),
|
|
1001
|
-
]);
|
|
1002
|
-
densityScale = (densityResult.density || 420) / 160;
|
|
1003
|
-
if (statusBarResult.success && statusBarResult.heightPixels) {
|
|
1004
|
-
statusBarPixels = statusBarResult.heightPixels;
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
catch { /* default */ }
|
|
1008
|
-
let uiNodes;
|
|
1009
|
-
try {
|
|
1010
|
-
const { androidGetUITree } = await import("./android.js");
|
|
1011
|
-
const tree = await androidGetUITree();
|
|
1012
|
-
if (tree.success && tree.elements)
|
|
1013
|
-
uiNodes = tree.elements;
|
|
1014
|
-
}
|
|
1015
|
-
catch { /* uiautomator unavailable */ }
|
|
1016
|
-
const norm = (s) => (s ?? "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
1017
|
-
const findUiMatch = (el) => {
|
|
1018
|
-
if (!uiNodes || uiNodes.length === 0)
|
|
1019
|
-
return null;
|
|
1020
|
-
const targetTestID = norm(el.testID);
|
|
1021
|
-
const targetText = norm(el.text);
|
|
1022
|
-
const targetA11y = norm(el.accessibilityLabel);
|
|
1023
|
-
if (!targetTestID && !targetText && !targetA11y)
|
|
1024
|
-
return null;
|
|
1025
|
-
// Prefer testID → resourceId exact match (most stable).
|
|
1026
|
-
if (targetTestID) {
|
|
1027
|
-
const m = uiNodes.find(u => norm(u.resourceId).endsWith("/" + targetTestID) || norm(u.resourceId) === targetTestID);
|
|
1028
|
-
if (m)
|
|
1029
|
-
return m;
|
|
1030
|
-
}
|
|
1031
|
-
// Then exact text/contentDesc match.
|
|
1032
|
-
for (const query of [targetText, targetA11y]) {
|
|
1033
|
-
if (!query)
|
|
1034
|
-
continue;
|
|
1035
|
-
const m = uiNodes.find(u => norm(u.text) === query || norm(u.contentDesc) === query);
|
|
1036
|
-
if (m)
|
|
1037
|
-
return m;
|
|
1038
|
-
}
|
|
1039
|
-
// Then substring — handles fiber's collected text being a superset of one node's label.
|
|
1040
|
-
for (const query of [targetText, targetA11y]) {
|
|
1041
|
-
if (!query)
|
|
1042
|
-
continue;
|
|
1043
|
-
const m = uiNodes.find(u => {
|
|
1044
|
-
const ut = norm(u.text);
|
|
1045
|
-
const ud = norm(u.contentDesc);
|
|
1046
|
-
return (ut && (ut.includes(query) || query.includes(ut))) ||
|
|
1047
|
-
(ud && (ud.includes(query) || query.includes(ud)));
|
|
1048
|
-
});
|
|
1049
|
-
if (m)
|
|
1050
|
-
return m;
|
|
1051
|
-
}
|
|
1052
|
-
return null;
|
|
1053
|
-
};
|
|
1054
|
-
for (const el of pressableElements) {
|
|
1055
|
-
const match = findUiMatch(el);
|
|
1056
|
-
if (match) {
|
|
1057
|
-
el.center = { x: match.center.x, y: match.center.y };
|
|
1058
|
-
el.frame = {
|
|
1059
|
-
x: match.bounds.left,
|
|
1060
|
-
y: match.bounds.top,
|
|
1061
|
-
width: match.bounds.width,
|
|
1062
|
-
height: match.bounds.height,
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
else {
|
|
1066
|
-
// Fall back to fiber's DP → device-pixel scaling, plus the dynamic
|
|
1067
|
-
// status-bar pixel offset so the y origin lines up with display
|
|
1068
|
-
// coordinates (uiautomator's space). measureInWindow on Bridgeless
|
|
1069
|
-
// returns coordinates relative to the React window which sits
|
|
1070
|
-
// below the system status bar on non-edge-to-edge apps; without
|
|
1071
|
-
// this shift, fallback elements report y-values that are
|
|
1072
|
-
// ~status-bar-height too small.
|
|
1073
|
-
//
|
|
1074
|
-
// Note: this is a best-effort correction. Edge-to-edge apps where
|
|
1075
|
-
// the React window starts at the display origin will see a
|
|
1076
|
-
// small over-correction here. The proper fix would query
|
|
1077
|
-
// WindowInsets, but the data isn't exposed via adb in a way
|
|
1078
|
-
// we can reach today, and the icon-only-no-text fallback is
|
|
1079
|
-
// already a "region hint" path — agents should prefer
|
|
1080
|
-
// tap(component=...) / tap(testID=...) for precision.
|
|
1081
|
-
el.center = {
|
|
1082
|
-
x: el.center.x * densityScale,
|
|
1083
|
-
y: el.center.y * densityScale + statusBarPixels,
|
|
1084
|
-
};
|
|
1085
|
-
el.frame = {
|
|
1086
|
-
x: el.frame.x * densityScale,
|
|
1087
|
-
y: el.frame.y * densityScale + statusBarPixels,
|
|
1088
|
-
width: el.frame.width * densityScale,
|
|
1089
|
-
height: el.frame.height * densityScale,
|
|
1090
|
-
};
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
// Format as readable text
|
|
1095
|
-
const lines = [parsed.summary, ""];
|
|
1096
|
-
for (let i = 0; i < pressableElements.length; i++) {
|
|
1097
|
-
const el = pressableElements[i];
|
|
1098
|
-
const num = i + 1;
|
|
1099
|
-
const iconHint = !el.hasLabel ? iconLabel(el.component, el.icon) : null;
|
|
1100
|
-
const label = el.hasLabel
|
|
1101
|
-
? `"${el.text}"`
|
|
1102
|
-
: iconHint
|
|
1103
|
-
? `(${iconHint})`
|
|
1104
|
-
: el.intent
|
|
1105
|
-
? `(${el.intent} icon)`
|
|
1106
|
-
: "(icon/image)";
|
|
1107
|
-
const ids = [];
|
|
1108
|
-
if (el.testID)
|
|
1109
|
-
ids.push(`testID="${el.testID}"`);
|
|
1110
|
-
if (el.accessibilityLabel)
|
|
1111
|
-
ids.push(`a11y="${el.accessibilityLabel}"`);
|
|
1112
|
-
const idStr = ids.length > 0 ? ` [${ids.join(", ")}]` : "";
|
|
1113
|
-
const inputStr = el.isInput ? " (input)" : "";
|
|
1114
|
-
const wrapperStr = el.isWrapper ? " [wrapper — skip unless dismissing keyboard]" : "";
|
|
1115
|
-
const nearPart = el.nearbyText ? ` near "${el.nearbyText}"` : "";
|
|
1116
|
-
lines.push(`${num}. ${el.component} ${label}${nearPart} — center:(${el.center.x},${el.center.y}) frame:(${el.frame.x},${el.frame.y} ${el.frame.width}x${el.frame.height})${idStr}${inputStr}${wrapperStr}`);
|
|
1117
|
-
if (el.path)
|
|
1118
|
-
lines.push(` path: ${el.path}`);
|
|
1119
|
-
}
|
|
1120
|
-
return {
|
|
1121
|
-
success: true,
|
|
1122
|
-
result: lines.join("\n"),
|
|
1123
|
-
parsedElements: pressableElements
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
catch {
|
|
1127
|
-
// If parsing fails, return original result
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
return result;
|
|
1131
|
-
}
|
|
1132
73
|
// ============================================================================
|
|
1133
74
|
// Press Element (invoke onPress via React Fiber Tree)
|
|
1134
75
|
// ============================================================================
|
|
@@ -1232,7 +173,10 @@ export async function pressElement(options) {
|
|
|
1232
173
|
return null;
|
|
1233
174
|
}
|
|
1234
175
|
|
|
1235
|
-
|
|
176
|
+
// Shared with screenState/inputTarget — see injectedFilters.ts. This file used to
|
|
177
|
+
// carry two hand-pasted copies of this pattern; divergence between copies is what
|
|
178
|
+
// let inspect_at_point report a context provider as the touched element.
|
|
179
|
+
var RN_PRIMITIVES = ${RN_PRIMITIVES_SRC};
|
|
1236
180
|
|
|
1237
181
|
function isScreenHidden(name, props) {
|
|
1238
182
|
return isHiddenNavigationScene(name, props);
|