react-native-ai-devtools 1.1.4
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/LICENSE +32 -0
- package/README.md +1250 -0
- package/build/__tests__/helpers/fake-cdp-server.d.ts +56 -0
- package/build/__tests__/helpers/fake-cdp-server.d.ts.map +1 -0
- package/build/__tests__/helpers/fake-cdp-server.js +108 -0
- package/build/__tests__/helpers/fake-cdp-server.js.map +1 -0
- package/build/__tests__/integration/connection-health.test.d.ts +2 -0
- package/build/__tests__/integration/connection-health.test.d.ts.map +1 -0
- package/build/__tests__/integration/connection-health.test.js +151 -0
- package/build/__tests__/integration/connection-health.test.js.map +1 -0
- package/build/__tests__/integration/execute-in-app.test.d.ts +2 -0
- package/build/__tests__/integration/execute-in-app.test.d.ts.map +1 -0
- package/build/__tests__/integration/execute-in-app.test.js +115 -0
- package/build/__tests__/integration/execute-in-app.test.js.map +1 -0
- package/build/__tests__/integration/tools.test.d.ts +2 -0
- package/build/__tests__/integration/tools.test.d.ts.map +1 -0
- package/build/__tests__/integration/tools.test.js +228 -0
- package/build/__tests__/integration/tools.test.js.map +1 -0
- package/build/__tests__/setup.d.ts +2 -0
- package/build/__tests__/setup.d.ts.map +1 -0
- package/build/__tests__/setup.js +11 -0
- package/build/__tests__/setup.js.map +1 -0
- package/build/__tests__/unit/bundle.test.d.ts +2 -0
- package/build/__tests__/unit/bundle.test.d.ts.map +1 -0
- package/build/__tests__/unit/bundle.test.js +53 -0
- package/build/__tests__/unit/bundle.test.js.map +1 -0
- package/build/__tests__/unit/connection-health.test.d.ts +2 -0
- package/build/__tests__/unit/connection-health.test.d.ts.map +1 -0
- package/build/__tests__/unit/connection-health.test.js +28 -0
- package/build/__tests__/unit/connection-health.test.js.map +1 -0
- package/build/__tests__/unit/executor.test.d.ts +2 -0
- package/build/__tests__/unit/executor.test.d.ts.map +1 -0
- package/build/__tests__/unit/executor.test.js +79 -0
- package/build/__tests__/unit/executor.test.js.map +1 -0
- package/build/__tests__/unit/logs.test.d.ts +2 -0
- package/build/__tests__/unit/logs.test.d.ts.map +1 -0
- package/build/__tests__/unit/logs.test.js +81 -0
- package/build/__tests__/unit/logs.test.js.map +1 -0
- package/build/__tests__/unit/metro.test.d.ts +2 -0
- package/build/__tests__/unit/metro.test.d.ts.map +1 -0
- package/build/__tests__/unit/metro.test.js +61 -0
- package/build/__tests__/unit/metro.test.js.map +1 -0
- package/build/__tests__/unit/network.test.d.ts +2 -0
- package/build/__tests__/unit/network.test.d.ts.map +1 -0
- package/build/__tests__/unit/network.test.js +102 -0
- package/build/__tests__/unit/network.test.js.map +1 -0
- package/build/__tests__/unit/tap.test.d.ts +2 -0
- package/build/__tests__/unit/tap.test.d.ts.map +1 -0
- package/build/__tests__/unit/tap.test.js +157 -0
- package/build/__tests__/unit/tap.test.js.map +1 -0
- package/build/core/android.d.ts +265 -0
- package/build/core/android.d.ts.map +1 -0
- package/build/core/android.js +1413 -0
- package/build/core/android.js.map +1 -0
- package/build/core/bundle.d.ts +49 -0
- package/build/core/bundle.d.ts.map +1 -0
- package/build/core/bundle.js +368 -0
- package/build/core/bundle.js.map +1 -0
- package/build/core/connection.d.ts +43 -0
- package/build/core/connection.d.ts.map +1 -0
- package/build/core/connection.js +963 -0
- package/build/core/connection.js.map +1 -0
- package/build/core/connectionState.d.ts +108 -0
- package/build/core/connectionState.d.ts.map +1 -0
- package/build/core/connectionState.js +284 -0
- package/build/core/connectionState.js.map +1 -0
- package/build/core/errorScreenParser.d.ts +30 -0
- package/build/core/errorScreenParser.d.ts.map +1 -0
- package/build/core/errorScreenParser.js +198 -0
- package/build/core/errorScreenParser.js.map +1 -0
- package/build/core/executor.d.ts +113 -0
- package/build/core/executor.d.ts.map +1 -0
- package/build/core/executor.js +1877 -0
- package/build/core/executor.js.map +1 -0
- package/build/core/format.d.ts +8 -0
- package/build/core/format.d.ts.map +1 -0
- package/build/core/format.js +34 -0
- package/build/core/format.js.map +1 -0
- package/build/core/guides.d.ts +14 -0
- package/build/core/guides.d.ts.map +1 -0
- package/build/core/guides.js +261 -0
- package/build/core/guides.js.map +1 -0
- package/build/core/httpServer.d.ts +14 -0
- package/build/core/httpServer.d.ts.map +1 -0
- package/build/core/httpServer.js +2459 -0
- package/build/core/httpServer.js.map +1 -0
- package/build/core/httpServerProcess.d.ts +25 -0
- package/build/core/httpServerProcess.d.ts.map +1 -0
- package/build/core/httpServerProcess.js +153 -0
- package/build/core/httpServerProcess.js.map +1 -0
- package/build/core/index.d.ts +25 -0
- package/build/core/index.d.ts.map +1 -0
- package/build/core/index.js +53 -0
- package/build/core/index.js.map +1 -0
- package/build/core/ios.d.ts +214 -0
- package/build/core/ios.d.ts.map +1 -0
- package/build/core/ios.js +1232 -0
- package/build/core/ios.js.map +1 -0
- package/build/core/logs.d.ts +43 -0
- package/build/core/logs.d.ts.map +1 -0
- package/build/core/logs.js +144 -0
- package/build/core/logs.js.map +1 -0
- package/build/core/metro.d.ts +23 -0
- package/build/core/metro.d.ts.map +1 -0
- package/build/core/metro.js +96 -0
- package/build/core/metro.js.map +1 -0
- package/build/core/network.d.ts +43 -0
- package/build/core/network.d.ts.map +1 -0
- package/build/core/network.js +217 -0
- package/build/core/network.js.map +1 -0
- package/build/core/networkInterceptor.d.ts +3 -0
- package/build/core/networkInterceptor.d.ts.map +1 -0
- package/build/core/networkInterceptor.js +203 -0
- package/build/core/networkInterceptor.js.map +1 -0
- package/build/core/ocr.d.ts +69 -0
- package/build/core/ocr.d.ts.map +1 -0
- package/build/core/ocr.js +212 -0
- package/build/core/ocr.js.map +1 -0
- package/build/core/state.d.ts +17 -0
- package/build/core/state.d.ts.map +1 -0
- package/build/core/state.js +50 -0
- package/build/core/state.js.map +1 -0
- package/build/core/tap.d.ts +91 -0
- package/build/core/tap.d.ts.map +1 -0
- package/build/core/tap.js +542 -0
- package/build/core/tap.js.map +1 -0
- package/build/core/telemetry.d.ts +4 -0
- package/build/core/telemetry.d.ts.map +1 -0
- package/build/core/telemetry.js +289 -0
- package/build/core/telemetry.js.map +1 -0
- package/build/core/types.d.ts +134 -0
- package/build/core/types.d.ts.map +1 -0
- package/build/core/types.js +2 -0
- package/build/core/types.js.map +1 -0
- package/build/httpServerStandalone.d.ts +7 -0
- package/build/httpServerStandalone.d.ts.map +1 -0
- package/build/httpServerStandalone.js +31 -0
- package/build/httpServerStandalone.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3012 -0
- package/build/index.js.map +1 -0
- package/build/pro/tap.d.ts +91 -0
- package/build/pro/tap.d.ts.map +1 -0
- package/build/pro/tap.js +542 -0
- package/build/pro/tap.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,1877 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { pendingExecutions, getNextMessageId, connectedApps } from "./state.js";
|
|
3
|
+
import { getFirstConnectedApp, connectToDevice } from "./connection.js";
|
|
4
|
+
import { fetchDevices, selectMainDevice, scanMetroPorts } from "./metro.js";
|
|
5
|
+
import { DEFAULT_RECONNECTION_CONFIG, cancelReconnectionTimer } from "./connectionState.js";
|
|
6
|
+
// Hermes runtime compatibility: polyfill for 'global' which doesn't exist in Hermes
|
|
7
|
+
// In Hermes, globalThis is the standard way to access global scope
|
|
8
|
+
const GLOBAL_POLYFILL = `var global = typeof global !== 'undefined' ? global : globalThis;`;
|
|
9
|
+
/**
|
|
10
|
+
* Check if a string contains emoji or other problematic Unicode characters
|
|
11
|
+
* Hermes has issues with certain UTF-16 surrogate pairs (like emoji)
|
|
12
|
+
*/
|
|
13
|
+
export function containsProblematicUnicode(str) {
|
|
14
|
+
// Detect UTF-16 surrogate pairs (emoji and other characters outside BMP)
|
|
15
|
+
// These cause "Invalid UTF-8 code point" errors in Hermes
|
|
16
|
+
// eslint-disable-next-line no-control-regex
|
|
17
|
+
return /[\uD800-\uDFFF]/.test(str);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Strip leading comments from an expression
|
|
21
|
+
* Users often start with // comments which break the (return expr) wrapping
|
|
22
|
+
*/
|
|
23
|
+
export function stripLeadingComments(expression) {
|
|
24
|
+
let result = expression;
|
|
25
|
+
// Strip leading whitespace first
|
|
26
|
+
result = result.trimStart();
|
|
27
|
+
// Repeatedly strip leading single-line comments (// ...)
|
|
28
|
+
while (result.startsWith("//")) {
|
|
29
|
+
const newlineIndex = result.indexOf("\n");
|
|
30
|
+
if (newlineIndex === -1) {
|
|
31
|
+
// Entire expression is a comment
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
result = result.slice(newlineIndex + 1).trimStart();
|
|
35
|
+
}
|
|
36
|
+
// Strip leading multi-line comments (/* ... */)
|
|
37
|
+
while (result.startsWith("/*")) {
|
|
38
|
+
const endIndex = result.indexOf("*/");
|
|
39
|
+
if (endIndex === -1) {
|
|
40
|
+
// Unclosed comment
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
result = result.slice(endIndex + 2).trimStart();
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validate and preprocess an expression before execution
|
|
49
|
+
* Returns cleaned expression or error with helpful message
|
|
50
|
+
*/
|
|
51
|
+
export function validateAndPreprocessExpression(expression) {
|
|
52
|
+
// Check for emoji/problematic Unicode before any processing
|
|
53
|
+
if (containsProblematicUnicode(expression)) {
|
|
54
|
+
return {
|
|
55
|
+
valid: false,
|
|
56
|
+
expression,
|
|
57
|
+
error: "Expression contains emoji or special Unicode characters that Hermes cannot compile. " +
|
|
58
|
+
"Please remove emoji and use ASCII characters only."
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Strip leading comments that would break the expression wrapper
|
|
62
|
+
const cleaned = stripLeadingComments(expression);
|
|
63
|
+
if (!cleaned.trim()) {
|
|
64
|
+
return {
|
|
65
|
+
valid: false,
|
|
66
|
+
expression,
|
|
67
|
+
error: "Expression is empty or contains only comments."
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Check for top-level async that Hermes doesn't support in Runtime.evaluate
|
|
71
|
+
// Pattern: starts with (async or async keyword at expression level
|
|
72
|
+
const trimmed = cleaned.trim();
|
|
73
|
+
if (trimmed.startsWith("(async") || trimmed.startsWith("async ") || trimmed.startsWith("async(")) {
|
|
74
|
+
return {
|
|
75
|
+
valid: false,
|
|
76
|
+
expression: cleaned,
|
|
77
|
+
error: "Hermes does not support top-level async functions in Runtime.evaluate. " +
|
|
78
|
+
"Instead of `(async () => { ... })()`, use a synchronous approach or " +
|
|
79
|
+
"execute the async code and access the result via a global variable: " +
|
|
80
|
+
"`global.__result = null; myAsyncFn().then(r => global.__result = r)`"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
valid: true,
|
|
85
|
+
expression: cleaned
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// Error patterns that indicate a stale/destroyed context
|
|
89
|
+
const CONTEXT_ERROR_PATTERNS = [
|
|
90
|
+
"cannot find context",
|
|
91
|
+
"execution context was destroyed",
|
|
92
|
+
"target closed",
|
|
93
|
+
"inspected target navigated",
|
|
94
|
+
"session closed",
|
|
95
|
+
"context with specified id",
|
|
96
|
+
"no execution context",
|
|
97
|
+
"runningdetached"
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* Check if an error indicates a stale page context
|
|
101
|
+
*/
|
|
102
|
+
function isContextError(error) {
|
|
103
|
+
if (!error)
|
|
104
|
+
return false;
|
|
105
|
+
const lowerError = error.toLowerCase();
|
|
106
|
+
return CONTEXT_ERROR_PATTERNS.some((pattern) => lowerError.includes(pattern));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Simple delay helper
|
|
110
|
+
*/
|
|
111
|
+
function delay(ms) {
|
|
112
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Attempt quick reconnection to Metro
|
|
116
|
+
*/
|
|
117
|
+
async function attemptQuickReconnect(preferredPort) {
|
|
118
|
+
try {
|
|
119
|
+
const ports = await scanMetroPorts();
|
|
120
|
+
const targetPort = preferredPort && ports.includes(preferredPort) ? preferredPort : ports[0];
|
|
121
|
+
if (!targetPort)
|
|
122
|
+
return false;
|
|
123
|
+
const devices = await fetchDevices(targetPort);
|
|
124
|
+
const mainDevice = selectMainDevice(devices);
|
|
125
|
+
if (!mainDevice)
|
|
126
|
+
return false;
|
|
127
|
+
await connectToDevice(mainDevice, targetPort);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Execute expression on a connected app (core implementation without retry)
|
|
136
|
+
*/
|
|
137
|
+
async function executeExpressionCore(expression, awaitPromise, timeoutMs = 10000) {
|
|
138
|
+
const app = getFirstConnectedApp();
|
|
139
|
+
if (!app) {
|
|
140
|
+
return { success: false, error: "No apps connected. Run 'scan_metro' first." };
|
|
141
|
+
}
|
|
142
|
+
if (app.ws.readyState !== WebSocket.OPEN) {
|
|
143
|
+
return { success: false, error: "WebSocket connection is not open." };
|
|
144
|
+
}
|
|
145
|
+
// Validate and preprocess the expression
|
|
146
|
+
const validation = validateAndPreprocessExpression(expression);
|
|
147
|
+
if (!validation.valid) {
|
|
148
|
+
return { success: false, error: validation.error };
|
|
149
|
+
}
|
|
150
|
+
const cleanedExpression = validation.expression;
|
|
151
|
+
const TIMEOUT_MS = timeoutMs;
|
|
152
|
+
const currentMessageId = getNextMessageId();
|
|
153
|
+
// Prepend global polyfill for Hermes compatibility
|
|
154
|
+
// Runtime.evaluate returns the completion value of the last expression naturally,
|
|
155
|
+
// so no IIFE wrapping is needed (similar to browser console behavior)
|
|
156
|
+
const wrappedExpression = `${GLOBAL_POLYFILL} ${cleanedExpression}`;
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
const timeoutId = setTimeout(() => {
|
|
159
|
+
pendingExecutions.delete(currentMessageId);
|
|
160
|
+
resolve({ success: false, error: "Timeout: Expression took too long to evaluate" });
|
|
161
|
+
}, TIMEOUT_MS);
|
|
162
|
+
pendingExecutions.set(currentMessageId, { resolve, timeoutId });
|
|
163
|
+
try {
|
|
164
|
+
app.ws.send(JSON.stringify({
|
|
165
|
+
id: currentMessageId,
|
|
166
|
+
method: "Runtime.evaluate",
|
|
167
|
+
params: {
|
|
168
|
+
expression: wrappedExpression,
|
|
169
|
+
returnByValue: true,
|
|
170
|
+
awaitPromise,
|
|
171
|
+
userGesture: true,
|
|
172
|
+
generatePreview: true
|
|
173
|
+
}
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
clearTimeout(timeoutId);
|
|
178
|
+
pendingExecutions.delete(currentMessageId);
|
|
179
|
+
resolve({
|
|
180
|
+
success: false,
|
|
181
|
+
error: `Failed to send: ${error instanceof Error ? error.message : String(error)}`
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// Execute JavaScript in the connected React Native app with retry logic
|
|
187
|
+
export async function executeInApp(expression, awaitPromise = true, options = {}) {
|
|
188
|
+
const { maxRetries = 2, retryDelayMs = 1000, autoReconnect = true, timeoutMs = 10000 } = options;
|
|
189
|
+
let lastError;
|
|
190
|
+
let preferredPort;
|
|
191
|
+
// Get preferred port from current connection if available
|
|
192
|
+
const currentApp = getFirstConnectedApp();
|
|
193
|
+
if (currentApp) {
|
|
194
|
+
preferredPort = currentApp.port;
|
|
195
|
+
}
|
|
196
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
197
|
+
const app = getFirstConnectedApp();
|
|
198
|
+
// No connection - try to reconnect if enabled
|
|
199
|
+
if (!app) {
|
|
200
|
+
if (autoReconnect && attempt < maxRetries) {
|
|
201
|
+
console.error(`[rn-ai-debugger] No connection, attempting reconnect (attempt ${attempt + 1}/${maxRetries})...`);
|
|
202
|
+
const reconnected = await attemptQuickReconnect(preferredPort);
|
|
203
|
+
if (reconnected) {
|
|
204
|
+
await delay(retryDelayMs);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { success: false, error: "No apps connected. Run 'scan_metro' first." };
|
|
209
|
+
}
|
|
210
|
+
// WebSocket not open - try to reconnect
|
|
211
|
+
if (app.ws.readyState !== WebSocket.OPEN) {
|
|
212
|
+
if (autoReconnect && attempt < maxRetries) {
|
|
213
|
+
console.error(`[rn-ai-debugger] WebSocket not open, attempting reconnect (attempt ${attempt + 1}/${maxRetries})...`);
|
|
214
|
+
// Close stale connection
|
|
215
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
216
|
+
cancelReconnectionTimer(appKey);
|
|
217
|
+
try {
|
|
218
|
+
app.ws.close();
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* ignore */
|
|
222
|
+
}
|
|
223
|
+
connectedApps.delete(appKey);
|
|
224
|
+
const reconnected = await attemptQuickReconnect(app.port);
|
|
225
|
+
if (reconnected) {
|
|
226
|
+
await delay(retryDelayMs);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { success: false, error: "WebSocket connection is not open." };
|
|
231
|
+
}
|
|
232
|
+
// Execute the expression
|
|
233
|
+
const result = await executeExpressionCore(expression, awaitPromise, timeoutMs);
|
|
234
|
+
// Success - return result
|
|
235
|
+
if (result.success) {
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
lastError = result.error;
|
|
239
|
+
// Check if this is a context error that might be recoverable
|
|
240
|
+
if (isContextError(result.error)) {
|
|
241
|
+
if (autoReconnect && attempt < maxRetries) {
|
|
242
|
+
console.error(`[rn-ai-debugger] Context error detected, attempting reconnect (attempt ${attempt + 1}/${maxRetries})...`);
|
|
243
|
+
// Close and reconnect
|
|
244
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
245
|
+
cancelReconnectionTimer(appKey);
|
|
246
|
+
try {
|
|
247
|
+
app.ws.close();
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
/* ignore */
|
|
251
|
+
}
|
|
252
|
+
connectedApps.delete(appKey);
|
|
253
|
+
const reconnected = await attemptQuickReconnect(app.port);
|
|
254
|
+
if (reconnected) {
|
|
255
|
+
await delay(retryDelayMs);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Non-context error or no more retries - return error
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
success: false,
|
|
265
|
+
error: lastError ?? "Execution failed after all retries. Connection may be stale."
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// List globally available debugging objects in the app
|
|
269
|
+
export async function listDebugGlobals() {
|
|
270
|
+
const expression = `
|
|
271
|
+
(function() {
|
|
272
|
+
const globals = Object.keys(globalThis);
|
|
273
|
+
const categories = {
|
|
274
|
+
'Apollo Client': globals.filter(k => k.includes('APOLLO')),
|
|
275
|
+
'Redux': globals.filter(k => k.includes('REDUX')),
|
|
276
|
+
'React DevTools': globals.filter(k => k.includes('REACT_DEVTOOLS')),
|
|
277
|
+
'Reanimated': globals.filter(k => k.includes('reanimated') || k.includes('worklet')),
|
|
278
|
+
'Expo': globals.filter(k => k.includes('Expo') || k.includes('expo')),
|
|
279
|
+
'Metro': globals.filter(k => k.includes('METRO')),
|
|
280
|
+
'Other Debug': globals.filter(k => k.startsWith('__') && !k.includes('APOLLO') && !k.includes('REDUX') && !k.includes('REACT_DEVTOOLS') && !k.includes('reanimated') && !k.includes('worklet') && !k.includes('Expo') && !k.includes('expo') && !k.includes('METRO'))
|
|
281
|
+
};
|
|
282
|
+
return categories;
|
|
283
|
+
})()
|
|
284
|
+
`;
|
|
285
|
+
return executeInApp(expression, false);
|
|
286
|
+
}
|
|
287
|
+
// Inspect a global object to see its properties and types
|
|
288
|
+
export async function inspectGlobal(objectName) {
|
|
289
|
+
const expression = `
|
|
290
|
+
(function() {
|
|
291
|
+
const obj = ${objectName};
|
|
292
|
+
if (obj === undefined) return { error: 'Object not found' };
|
|
293
|
+
const result = {};
|
|
294
|
+
for (const key of Object.keys(obj)) {
|
|
295
|
+
const val = obj[key];
|
|
296
|
+
const type = typeof val;
|
|
297
|
+
if (type === 'function') {
|
|
298
|
+
result[key] = { type: 'function', callable: true };
|
|
299
|
+
} else if (type === 'object' && val !== null) {
|
|
300
|
+
result[key] = { type: Array.isArray(val) ? 'array' : 'object', callable: false, preview: JSON.stringify(val).slice(0, 100) };
|
|
301
|
+
} else {
|
|
302
|
+
result[key] = { type, callable: false, value: val };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return result;
|
|
306
|
+
})()
|
|
307
|
+
`;
|
|
308
|
+
return executeInApp(expression, false);
|
|
309
|
+
}
|
|
310
|
+
// Reload the React Native app using __ReactRefresh (Page.reload is not supported by Hermes)
|
|
311
|
+
// Uses fire-and-forget: sends the reload command without waiting for a response,
|
|
312
|
+
// since the JS context is destroyed during reload and would always timeout.
|
|
313
|
+
export async function reloadApp() {
|
|
314
|
+
// Get current connection info before reload
|
|
315
|
+
let app = getFirstConnectedApp();
|
|
316
|
+
// Auto-connect if no connection exists
|
|
317
|
+
if (!app) {
|
|
318
|
+
console.error("[rn-ai-debugger] No connection for reload, attempting auto-connect...");
|
|
319
|
+
// Try to find and connect to a Metro server
|
|
320
|
+
const ports = await scanMetroPorts();
|
|
321
|
+
if (ports.length === 0) {
|
|
322
|
+
return {
|
|
323
|
+
success: false,
|
|
324
|
+
error: "No apps connected and no Metro server found. Make sure Metro bundler is running (npm start or expo start), then try again."
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
// Try to connect to the first available Metro server
|
|
328
|
+
for (const port of ports) {
|
|
329
|
+
const devices = await fetchDevices(port);
|
|
330
|
+
const mainDevice = selectMainDevice(devices);
|
|
331
|
+
if (mainDevice) {
|
|
332
|
+
try {
|
|
333
|
+
await connectToDevice(mainDevice, port);
|
|
334
|
+
console.error(`[rn-ai-debugger] Auto-connected to ${mainDevice.title} on port ${port}`);
|
|
335
|
+
app = getFirstConnectedApp();
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
console.error(`[rn-ai-debugger] Failed to connect to port ${port}: ${error}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Check if auto-connect succeeded
|
|
344
|
+
if (!app) {
|
|
345
|
+
return {
|
|
346
|
+
success: false,
|
|
347
|
+
error: "No apps connected. Found Metro server but could not connect to any device. Make sure the React Native app is running."
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const port = app.port;
|
|
352
|
+
// Fire-and-forget: send reload command via CDP without waiting for response.
|
|
353
|
+
// The JS context is destroyed during reload, so Runtime.evaluate would always timeout.
|
|
354
|
+
const reloadExpression = `(function() {
|
|
355
|
+
try {
|
|
356
|
+
if (typeof __ReactRefresh !== 'undefined' && typeof __ReactRefresh.performFullRefresh === 'function') {
|
|
357
|
+
__ReactRefresh.performFullRefresh('mcp-reload');
|
|
358
|
+
return 'ok';
|
|
359
|
+
}
|
|
360
|
+
if (typeof global !== 'undefined' && global.DevSettings && typeof global.DevSettings.reload === 'function') {
|
|
361
|
+
global.DevSettings.reload();
|
|
362
|
+
return 'ok';
|
|
363
|
+
}
|
|
364
|
+
return 'no-method';
|
|
365
|
+
} catch (e) { return 'error:' + e.message; }
|
|
366
|
+
})()`;
|
|
367
|
+
try {
|
|
368
|
+
if (app.ws.readyState !== WebSocket.OPEN) {
|
|
369
|
+
return { success: false, error: "WebSocket connection is not open." };
|
|
370
|
+
}
|
|
371
|
+
// Send without registering a pending execution — fire and forget
|
|
372
|
+
const messageId = getNextMessageId();
|
|
373
|
+
app.ws.send(JSON.stringify({
|
|
374
|
+
id: messageId,
|
|
375
|
+
method: "Runtime.evaluate",
|
|
376
|
+
params: {
|
|
377
|
+
expression: reloadExpression,
|
|
378
|
+
returnByValue: true,
|
|
379
|
+
awaitPromise: false,
|
|
380
|
+
userGesture: true
|
|
381
|
+
}
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
return {
|
|
386
|
+
success: false,
|
|
387
|
+
error: `Failed to send reload command: ${error instanceof Error ? error.message : String(error)}`
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
// Auto-reconnect after reload
|
|
391
|
+
try {
|
|
392
|
+
// Wait for app to reload (give it time to restart JS context)
|
|
393
|
+
await delay(2000);
|
|
394
|
+
// Close existing connections to this port and cancel any pending auto-reconnections
|
|
395
|
+
// This prevents the dual-reconnection bug where both auto-reconnect and manual reconnect compete
|
|
396
|
+
for (const [key, connectedApp] of connectedApps.entries()) {
|
|
397
|
+
if (connectedApp.port === port) {
|
|
398
|
+
// Cancel any pending reconnection timer BEFORE closing
|
|
399
|
+
cancelReconnectionTimer(key);
|
|
400
|
+
try {
|
|
401
|
+
connectedApp.ws.close();
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// Ignore close errors
|
|
405
|
+
}
|
|
406
|
+
connectedApps.delete(key);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Small delay to ensure cleanup
|
|
410
|
+
await delay(500);
|
|
411
|
+
// Reconnect to Metro on the same port with auto-reconnection DISABLED
|
|
412
|
+
// We're doing a manual reconnection here, so we don't want the auto-reconnect
|
|
413
|
+
// system to also try reconnecting and compete with us
|
|
414
|
+
const devices = await fetchDevices(port);
|
|
415
|
+
const mainDevice = selectMainDevice(devices);
|
|
416
|
+
if (mainDevice) {
|
|
417
|
+
await connectToDevice(mainDevice, port, {
|
|
418
|
+
isReconnection: false,
|
|
419
|
+
reconnectionConfig: { ...DEFAULT_RECONNECTION_CONFIG, enabled: false }
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
success: true,
|
|
423
|
+
result: `App reloaded and reconnected to ${mainDevice.title}`
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
return {
|
|
428
|
+
success: true,
|
|
429
|
+
result: "App reloaded but could not auto-reconnect. Run 'scan_metro' to reconnect."
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
return {
|
|
435
|
+
success: true,
|
|
436
|
+
result: `App reloaded but auto-reconnect failed: ${error instanceof Error ? error.message : String(error)}. Run 'scan_metro' to reconnect.`
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function formatTreeToTonl(node, indent = 0) {
|
|
441
|
+
const prefix = " ".repeat(indent);
|
|
442
|
+
let result = `${prefix}${node.component}`;
|
|
443
|
+
// Add props inline if present
|
|
444
|
+
if (node.props && Object.keys(node.props).length > 0) {
|
|
445
|
+
const propsStr = Object.entries(node.props)
|
|
446
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
447
|
+
.join(",");
|
|
448
|
+
result += ` (${propsStr})`;
|
|
449
|
+
}
|
|
450
|
+
// Add layout inline if present
|
|
451
|
+
if (node.layout && Object.keys(node.layout).length > 0) {
|
|
452
|
+
const layoutStr = Object.entries(node.layout)
|
|
453
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
454
|
+
.join(",");
|
|
455
|
+
result += ` [${layoutStr}]`;
|
|
456
|
+
}
|
|
457
|
+
result += "\n";
|
|
458
|
+
// Recurse children
|
|
459
|
+
if (node.children && node.children.length > 0) {
|
|
460
|
+
for (const child of node.children) {
|
|
461
|
+
result += formatTreeToTonl(child, indent + 1);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
// Ultra-compact structure-only tree format (just component names, indented)
|
|
467
|
+
function formatTreeStructureOnly(node, indent = 0) {
|
|
468
|
+
const prefix = " ".repeat(indent);
|
|
469
|
+
let result = `${prefix}${node.component}\n`;
|
|
470
|
+
if (node.children && node.children.length > 0) {
|
|
471
|
+
for (const child of node.children) {
|
|
472
|
+
result += formatTreeStructureOnly(child, indent + 1);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
function formatScreenLayoutToTonl(elements) {
|
|
478
|
+
const lines = ["#elements{component,path,depth,layout,id}"];
|
|
479
|
+
for (const el of elements) {
|
|
480
|
+
const layout = el.layout
|
|
481
|
+
? Object.entries(el.layout)
|
|
482
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
483
|
+
.join(";")
|
|
484
|
+
: "";
|
|
485
|
+
const id = el.identifiers?.testID || el.identifiers?.accessibilityLabel || "";
|
|
486
|
+
lines.push(`${el.component}|${el.path}|${el.depth}|${layout}|${id}`);
|
|
487
|
+
}
|
|
488
|
+
return lines.join("\n");
|
|
489
|
+
}
|
|
490
|
+
function formatFoundComponentsToTonl(components) {
|
|
491
|
+
const lines = ["#found{component,path,depth,key,layout}"];
|
|
492
|
+
for (const c of components) {
|
|
493
|
+
const layout = c.layout
|
|
494
|
+
? Object.entries(c.layout)
|
|
495
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
496
|
+
.join(";")
|
|
497
|
+
: "";
|
|
498
|
+
lines.push(`${c.component}|${c.path}|${c.depth}|${c.key || ""}|${layout}`);
|
|
499
|
+
}
|
|
500
|
+
return lines.join("\n");
|
|
501
|
+
}
|
|
502
|
+
function formatSummaryToTonl(components, total) {
|
|
503
|
+
const lines = [`#summary total=${total}`];
|
|
504
|
+
for (const c of components) {
|
|
505
|
+
lines.push(`${c.component}:${c.count}`);
|
|
506
|
+
}
|
|
507
|
+
return lines.join("\n");
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Get the React component tree from the running app.
|
|
511
|
+
* This traverses the fiber tree to extract component hierarchy with names.
|
|
512
|
+
*/
|
|
513
|
+
export async function getComponentTree(options = {}) {
|
|
514
|
+
const { includeProps = false, includeStyles = false, hideInternals = true, format = "tonl", structureOnly = false, focusedOnly = false } = options;
|
|
515
|
+
// Use lower default depth for structureOnly to keep output compact (~2-5KB)
|
|
516
|
+
// Full mode uses higher depth since TONL format handles it better
|
|
517
|
+
// focusedOnly mode uses moderate depth since we're already filtering to active screen
|
|
518
|
+
const maxDepth = options.maxDepth ?? (structureOnly ? (focusedOnly ? 25 : 40) : 100);
|
|
519
|
+
const expression = `
|
|
520
|
+
(function() {
|
|
521
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
522
|
+
if (!hook) return { error: 'React DevTools hook not found. Make sure you are running a development build.' };
|
|
523
|
+
|
|
524
|
+
// Try to get fiber roots (renderer ID is usually 1)
|
|
525
|
+
let roots = [];
|
|
526
|
+
if (hook.getFiberRoots) {
|
|
527
|
+
roots = [...(hook.getFiberRoots(1) || [])];
|
|
528
|
+
}
|
|
529
|
+
if (roots.length === 0 && hook.renderers) {
|
|
530
|
+
// Try all renderers
|
|
531
|
+
for (const [id] of hook.renderers) {
|
|
532
|
+
const r = hook.getFiberRoots ? [...(hook.getFiberRoots(id) || [])] : [];
|
|
533
|
+
if (r.length > 0) {
|
|
534
|
+
roots = r;
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (roots.length === 0) return { error: 'No fiber roots found. The app may not have rendered yet.' };
|
|
540
|
+
|
|
541
|
+
const maxDepth = ${maxDepth};
|
|
542
|
+
const includeProps = ${includeProps};
|
|
543
|
+
const includeStyles = ${includeStyles};
|
|
544
|
+
const hideInternals = ${hideInternals};
|
|
545
|
+
const focusedOnly = ${focusedOnly};
|
|
546
|
+
|
|
547
|
+
// Internal RN components to hide
|
|
548
|
+
const internalPatterns = /^(RCT|RNS|Animated\\(|AnimatedComponent|VirtualizedList|CellRenderer|ScrollViewContext|PerformanceLoggerContext|RootTagContext|HeaderShownContext|HeaderHeightContext|HeaderBackContext|SafeAreaFrameContext|SafeAreaInsetsContext|VirtualizedListContext|VirtualizedListCellContextProvider|StaticContainer|DelayedFreeze|Freeze|Suspender|DebugContainer|MaybeNestedStack|SceneView|NavigationContent|PreventRemoveProvider|EnsureSingleNavigator)/;
|
|
549
|
+
|
|
550
|
+
// Screen component patterns - user's actual screens (strict matching)
|
|
551
|
+
// Only match *Screen and *Page to avoid false positives like BottomTabView
|
|
552
|
+
const screenPatterns = /^[A-Z][a-zA-Z0-9]*(Screen|Page)$/;
|
|
553
|
+
|
|
554
|
+
// Navigation/internal screen patterns to SKIP (these look like screens but are framework components)
|
|
555
|
+
const internalScreenPatterns = /^(MaybeScreen|Screen$|ScreenContainer|ScreenStack|SceneView|Background$)/;
|
|
556
|
+
|
|
557
|
+
// Provider/wrapper patterns to skip when finding focused screen
|
|
558
|
+
const wrapperPatterns = /^(App|AppContainer|Provider|Context|SafeArea|Gesture|Theme|Redux|Root|Navigator|Stack|Tab|Drawer|Navigation|Container|Wrapper|Layout|ErrorBoundary|Suspense|PersistGate|LinkingContext|AppState|View|Fragment|NativeStack|BottomTab|Screen$)/i;
|
|
559
|
+
|
|
560
|
+
// Global overlay patterns - stop traversing into these subtrees
|
|
561
|
+
// Be specific to avoid blocking BottomSheetDrawer, PortalProvider, etc.
|
|
562
|
+
const overlayPatterns = /^(BottomSheet$|BottomSheetGlobal|Modal$|Toast$|Snackbar$|Dialog$|Overlay$|Popup$|MyToast$|PaywallModal$|FullScreenBannerModal$)/i;
|
|
563
|
+
|
|
564
|
+
// Navigation container patterns - skip traversing into these (screens inside are nav screens, not focused content)
|
|
565
|
+
const navContainerPatterns = /^(RootNavigation|NativeStackNavigator|BottomTabNavigator|DrawerNavigator|TabNavigator|StackNavigator)/;
|
|
566
|
+
|
|
567
|
+
function getComponentName(fiber) {
|
|
568
|
+
if (!fiber || !fiber.type) return null;
|
|
569
|
+
if (typeof fiber.type === 'string') return fiber.type; // Host component (View, Text, etc.)
|
|
570
|
+
return fiber.type.displayName || fiber.type.name || null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function shouldHide(name) {
|
|
574
|
+
if (!hideInternals || !name) return false;
|
|
575
|
+
return internalPatterns.test(name);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function extractLayoutStyles(style) {
|
|
579
|
+
if (!style) return null;
|
|
580
|
+
const merged = Array.isArray(style)
|
|
581
|
+
? Object.assign({}, ...style.filter(Boolean).map(s => typeof s === 'object' ? s : {}))
|
|
582
|
+
: (typeof style === 'object' ? style : {});
|
|
583
|
+
|
|
584
|
+
const layout = {};
|
|
585
|
+
const layoutKeys = [
|
|
586
|
+
'padding', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight',
|
|
587
|
+
'paddingHorizontal', 'paddingVertical',
|
|
588
|
+
'margin', 'marginTop', 'marginBottom', 'marginLeft', 'marginRight',
|
|
589
|
+
'marginHorizontal', 'marginVertical',
|
|
590
|
+
'width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight',
|
|
591
|
+
'flex', 'flexDirection', 'flexWrap', 'flexGrow', 'flexShrink',
|
|
592
|
+
'justifyContent', 'alignItems', 'alignSelf', 'alignContent',
|
|
593
|
+
'position', 'top', 'bottom', 'left', 'right',
|
|
594
|
+
'gap', 'rowGap', 'columnGap',
|
|
595
|
+
'borderWidth', 'borderTopWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderRightWidth'
|
|
596
|
+
];
|
|
597
|
+
|
|
598
|
+
for (const key of layoutKeys) {
|
|
599
|
+
if (merged[key] !== undefined) layout[key] = merged[key];
|
|
600
|
+
}
|
|
601
|
+
return Object.keys(layout).length > 0 ? layout : null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function walkFiber(fiber, depth) {
|
|
605
|
+
if (!fiber || depth > maxDepth) return null;
|
|
606
|
+
|
|
607
|
+
const name = getComponentName(fiber);
|
|
608
|
+
|
|
609
|
+
// Skip anonymous/internal components unless they have meaningful children
|
|
610
|
+
if (!name || shouldHide(name)) {
|
|
611
|
+
// Still traverse children
|
|
612
|
+
let child = fiber.child;
|
|
613
|
+
const children = [];
|
|
614
|
+
while (child) {
|
|
615
|
+
const childResult = walkFiber(child, depth);
|
|
616
|
+
if (childResult) children.push(childResult);
|
|
617
|
+
child = child.sibling;
|
|
618
|
+
}
|
|
619
|
+
// Return first meaningful child or null
|
|
620
|
+
return children.length === 1 ? children[0] : (children.length > 1 ? { component: '(Fragment)', children } : null);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const node = { component: name };
|
|
624
|
+
|
|
625
|
+
// Include props if requested (excluding children and style for cleaner output)
|
|
626
|
+
if (includeProps && fiber.memoizedProps) {
|
|
627
|
+
const props = {};
|
|
628
|
+
for (const key of Object.keys(fiber.memoizedProps)) {
|
|
629
|
+
if (key === 'children' || key === 'style') continue;
|
|
630
|
+
const val = fiber.memoizedProps[key];
|
|
631
|
+
if (typeof val === 'function') {
|
|
632
|
+
props[key] = '[Function]';
|
|
633
|
+
} else if (typeof val === 'object' && val !== null) {
|
|
634
|
+
props[key] = Array.isArray(val) ? '[Array]' : '[Object]';
|
|
635
|
+
} else {
|
|
636
|
+
props[key] = val;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (Object.keys(props).length > 0) node.props = props;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Include layout styles if requested
|
|
643
|
+
if (includeStyles && fiber.memoizedProps?.style) {
|
|
644
|
+
const layout = extractLayoutStyles(fiber.memoizedProps.style);
|
|
645
|
+
if (layout) node.layout = layout;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Traverse children
|
|
649
|
+
let child = fiber.child;
|
|
650
|
+
const children = [];
|
|
651
|
+
while (child) {
|
|
652
|
+
const childResult = walkFiber(child, depth + 1);
|
|
653
|
+
if (childResult) children.push(childResult);
|
|
654
|
+
child = child.sibling;
|
|
655
|
+
}
|
|
656
|
+
if (children.length > 0) node.children = children;
|
|
657
|
+
|
|
658
|
+
return node;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Find focused screen if requested
|
|
662
|
+
function findFocusedScreen(fiber, depth = 0) {
|
|
663
|
+
if (!fiber || depth > 200) return null;
|
|
664
|
+
|
|
665
|
+
const name = getComponentName(fiber);
|
|
666
|
+
|
|
667
|
+
// Skip overlays (BottomSheet, Modal, Toast, etc.) - don't traverse into them
|
|
668
|
+
if (name && overlayPatterns.test(name)) {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Skip navigation containers - screens inside are nav screens, not focused content
|
|
673
|
+
if (name && navContainerPatterns.test(name)) {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Check if this is a user's screen component (not framework internals)
|
|
678
|
+
if (name && screenPatterns.test(name) && !wrapperPatterns.test(name) && !internalScreenPatterns.test(name)) {
|
|
679
|
+
return fiber;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Search children
|
|
683
|
+
let child = fiber.child;
|
|
684
|
+
while (child) {
|
|
685
|
+
const found = findFocusedScreen(child, depth + 1);
|
|
686
|
+
if (found) return found;
|
|
687
|
+
child = child.sibling;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
let startFiber = roots[0].current;
|
|
694
|
+
let focusedScreenName = null;
|
|
695
|
+
|
|
696
|
+
if (focusedOnly) {
|
|
697
|
+
const focused = findFocusedScreen(roots[0].current);
|
|
698
|
+
if (focused) {
|
|
699
|
+
startFiber = focused;
|
|
700
|
+
focusedScreenName = getComponentName(focused);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const tree = walkFiber(startFiber, 0);
|
|
705
|
+
|
|
706
|
+
if (focusedOnly && focusedScreenName) {
|
|
707
|
+
return { focusedScreen: focusedScreenName, tree };
|
|
708
|
+
}
|
|
709
|
+
return { tree };
|
|
710
|
+
})()
|
|
711
|
+
`;
|
|
712
|
+
// Use a longer timeout for component tree traversal — large apps can exceed 10s
|
|
713
|
+
const result = await executeInApp(expression, false, { timeoutMs: 30000 });
|
|
714
|
+
// Apply formatting if requested
|
|
715
|
+
if (result.success && result.result) {
|
|
716
|
+
try {
|
|
717
|
+
const parsed = JSON.parse(result.result);
|
|
718
|
+
if (parsed.tree) {
|
|
719
|
+
const prefix = parsed.focusedScreen ? `Focused: ${parsed.focusedScreen}\n\n` : "";
|
|
720
|
+
// Structure-only mode: ultra-compact format with just component names
|
|
721
|
+
if (structureOnly) {
|
|
722
|
+
const structure = formatTreeStructureOnly(parsed.tree);
|
|
723
|
+
return { success: true, result: prefix + structure };
|
|
724
|
+
}
|
|
725
|
+
// TONL format: compact with props/layout
|
|
726
|
+
if (format === "tonl") {
|
|
727
|
+
const tonl = formatTreeToTonl(parsed.tree);
|
|
728
|
+
return { success: true, result: prefix + tonl };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
// If parsing fails, return original result
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return result;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Get layout styles for all components on the current screen.
|
|
740
|
+
* Useful for verifying layout without screenshots.
|
|
741
|
+
*/
|
|
742
|
+
export async function getScreenLayout(options = {}) {
|
|
743
|
+
const { maxDepth = 65, componentsOnly = false, shortPath = true, summary = false, format = "tonl" } = options;
|
|
744
|
+
const expression = `
|
|
745
|
+
(function() {
|
|
746
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
747
|
+
if (!hook) return { error: 'React DevTools hook not found.' };
|
|
748
|
+
|
|
749
|
+
let roots = [];
|
|
750
|
+
if (hook.getFiberRoots) {
|
|
751
|
+
roots = [...(hook.getFiberRoots(1) || [])];
|
|
752
|
+
}
|
|
753
|
+
if (roots.length === 0 && hook.renderers) {
|
|
754
|
+
for (const [id] of hook.renderers) {
|
|
755
|
+
const r = hook.getFiberRoots ? [...(hook.getFiberRoots(id) || [])] : [];
|
|
756
|
+
if (r.length > 0) { roots = r; break; }
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (roots.length === 0) return { error: 'No fiber roots found.' };
|
|
760
|
+
|
|
761
|
+
const maxDepth = ${maxDepth};
|
|
762
|
+
const componentsOnly = ${componentsOnly};
|
|
763
|
+
const shortPath = ${shortPath};
|
|
764
|
+
const summaryMode = ${summary};
|
|
765
|
+
const pathSegments = 3; // Number of path segments to show in shortPath mode
|
|
766
|
+
|
|
767
|
+
function getComponentName(fiber) {
|
|
768
|
+
if (!fiber || !fiber.type) return null;
|
|
769
|
+
if (typeof fiber.type === 'string') return fiber.type;
|
|
770
|
+
return fiber.type.displayName || fiber.type.name || null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function isHostComponent(fiber) {
|
|
774
|
+
return typeof fiber?.type === 'string';
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function formatPath(pathArray) {
|
|
778
|
+
if (!shortPath || pathArray.length <= pathSegments) {
|
|
779
|
+
return pathArray.join(' > ');
|
|
780
|
+
}
|
|
781
|
+
return '... > ' + pathArray.slice(-pathSegments).join(' > ');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function extractAllStyles(style) {
|
|
785
|
+
if (!style) return null;
|
|
786
|
+
const merged = Array.isArray(style)
|
|
787
|
+
? Object.assign({}, ...style.filter(Boolean).map(s => typeof s === 'object' ? s : {}))
|
|
788
|
+
: (typeof style === 'object' ? style : {});
|
|
789
|
+
return Object.keys(merged).length > 0 ? merged : null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function extractLayoutStyles(style) {
|
|
793
|
+
if (!style) return null;
|
|
794
|
+
const merged = Array.isArray(style)
|
|
795
|
+
? Object.assign({}, ...style.filter(Boolean).map(s => typeof s === 'object' ? s : {}))
|
|
796
|
+
: (typeof style === 'object' ? style : {});
|
|
797
|
+
|
|
798
|
+
const layout = {};
|
|
799
|
+
const layoutKeys = [
|
|
800
|
+
'padding', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight',
|
|
801
|
+
'paddingHorizontal', 'paddingVertical',
|
|
802
|
+
'margin', 'marginTop', 'marginBottom', 'marginLeft', 'marginRight',
|
|
803
|
+
'marginHorizontal', 'marginVertical',
|
|
804
|
+
'width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight',
|
|
805
|
+
'flex', 'flexDirection', 'flexWrap', 'flexGrow', 'flexShrink',
|
|
806
|
+
'justifyContent', 'alignItems', 'alignSelf', 'alignContent',
|
|
807
|
+
'position', 'top', 'bottom', 'left', 'right',
|
|
808
|
+
'gap', 'rowGap', 'columnGap',
|
|
809
|
+
'borderWidth', 'borderTopWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderRightWidth',
|
|
810
|
+
'backgroundColor', 'borderColor', 'borderRadius'
|
|
811
|
+
];
|
|
812
|
+
|
|
813
|
+
for (const key of layoutKeys) {
|
|
814
|
+
if (merged[key] !== undefined) layout[key] = merged[key];
|
|
815
|
+
}
|
|
816
|
+
return Object.keys(layout).length > 0 ? layout : null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const elements = [];
|
|
820
|
+
|
|
821
|
+
function walkFiber(fiber, depth, path) {
|
|
822
|
+
if (!fiber || depth > maxDepth) return;
|
|
823
|
+
|
|
824
|
+
const name = getComponentName(fiber);
|
|
825
|
+
const isHost = isHostComponent(fiber);
|
|
826
|
+
|
|
827
|
+
// Include host components (View, Text, etc.) or named components
|
|
828
|
+
if (name && (!componentsOnly || !isHost)) {
|
|
829
|
+
const style = fiber.memoizedProps?.style;
|
|
830
|
+
const layout = extractLayoutStyles(style);
|
|
831
|
+
|
|
832
|
+
// Get text content if it's a Text component
|
|
833
|
+
let textContent = null;
|
|
834
|
+
if (name === 'Text' || name === 'RCTText') {
|
|
835
|
+
const children = fiber.memoizedProps?.children;
|
|
836
|
+
if (typeof children === 'string') textContent = children;
|
|
837
|
+
else if (typeof children === 'number') textContent = String(children);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const element = {
|
|
841
|
+
component: name,
|
|
842
|
+
path: formatPath(path),
|
|
843
|
+
depth
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
if (layout) element.layout = layout;
|
|
847
|
+
if (textContent) element.text = textContent.slice(0, 100);
|
|
848
|
+
|
|
849
|
+
// Include key props for identification
|
|
850
|
+
if (fiber.memoizedProps) {
|
|
851
|
+
const identifiers = {};
|
|
852
|
+
if (fiber.memoizedProps.testID) identifiers.testID = fiber.memoizedProps.testID;
|
|
853
|
+
if (fiber.memoizedProps.accessibilityLabel) identifiers.accessibilityLabel = fiber.memoizedProps.accessibilityLabel;
|
|
854
|
+
if (fiber.memoizedProps.nativeID) identifiers.nativeID = fiber.memoizedProps.nativeID;
|
|
855
|
+
if (fiber.key) identifiers.key = fiber.key;
|
|
856
|
+
if (Object.keys(identifiers).length > 0) element.identifiers = identifiers;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
elements.push(element);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Traverse children
|
|
863
|
+
let child = fiber.child;
|
|
864
|
+
while (child) {
|
|
865
|
+
const childName = getComponentName(child);
|
|
866
|
+
walkFiber(child, depth + 1, childName ? [...path, childName] : path);
|
|
867
|
+
child = child.sibling;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
walkFiber(roots[0].current, 0, []);
|
|
872
|
+
|
|
873
|
+
// Summary mode: return counts by component name
|
|
874
|
+
if (summaryMode) {
|
|
875
|
+
const counts = {};
|
|
876
|
+
for (const el of elements) {
|
|
877
|
+
counts[el.component] = (counts[el.component] || 0) + 1;
|
|
878
|
+
}
|
|
879
|
+
// Sort by count descending
|
|
880
|
+
const sorted = Object.entries(counts)
|
|
881
|
+
.sort((a, b) => b[1] - a[1])
|
|
882
|
+
.map(([name, count]) => ({ component: name, count }));
|
|
883
|
+
return {
|
|
884
|
+
totalElements: elements.length,
|
|
885
|
+
uniqueComponents: sorted.length,
|
|
886
|
+
components: sorted
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
totalElements: elements.length,
|
|
892
|
+
elements: elements
|
|
893
|
+
};
|
|
894
|
+
})()
|
|
895
|
+
`;
|
|
896
|
+
// Use a longer timeout for layout traversal — large component trees can exceed 10s
|
|
897
|
+
const result = await executeInApp(expression, false, { timeoutMs: 30000 });
|
|
898
|
+
// Apply TONL formatting if requested
|
|
899
|
+
if (format === "tonl" && result.success && result.result) {
|
|
900
|
+
try {
|
|
901
|
+
const parsed = JSON.parse(result.result);
|
|
902
|
+
if (parsed.components) {
|
|
903
|
+
// Summary mode
|
|
904
|
+
const tonl = formatSummaryToTonl(parsed.components, parsed.totalElements);
|
|
905
|
+
return { success: true, result: tonl };
|
|
906
|
+
}
|
|
907
|
+
else if (parsed.elements) {
|
|
908
|
+
// Full element list
|
|
909
|
+
const tonl = formatScreenLayoutToTonl(parsed.elements);
|
|
910
|
+
return { success: true, result: tonl };
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
// If parsing fails, return original result
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return result;
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Inspect a specific component by name, returning its props, state, and layout.
|
|
921
|
+
*/
|
|
922
|
+
export async function inspectComponent(componentName, options = {}) {
|
|
923
|
+
const { index = 0, includeState = true, includeChildren = false, childrenDepth = 1, shortPath = true, simplifyHooks = true } = options;
|
|
924
|
+
const escapedName = componentName.replace(/'/g, "\\'");
|
|
925
|
+
const expression = `
|
|
926
|
+
(function() {
|
|
927
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
928
|
+
if (!hook) return { error: 'React DevTools hook not found.' };
|
|
929
|
+
|
|
930
|
+
let roots = [];
|
|
931
|
+
if (hook.getFiberRoots) {
|
|
932
|
+
roots = [...(hook.getFiberRoots(1) || [])];
|
|
933
|
+
}
|
|
934
|
+
if (roots.length === 0 && hook.renderers) {
|
|
935
|
+
for (const [id] of hook.renderers) {
|
|
936
|
+
const r = hook.getFiberRoots ? [...(hook.getFiberRoots(id) || [])] : [];
|
|
937
|
+
if (r.length > 0) { roots = r; break; }
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (roots.length === 0) return { error: 'No fiber roots found.' };
|
|
941
|
+
|
|
942
|
+
const targetName = '${escapedName}';
|
|
943
|
+
const targetIndex = ${index};
|
|
944
|
+
const includeState = ${includeState};
|
|
945
|
+
const includeChildren = ${includeChildren};
|
|
946
|
+
const childrenDepth = ${childrenDepth};
|
|
947
|
+
const shortPath = ${shortPath};
|
|
948
|
+
const simplifyHooks = ${simplifyHooks};
|
|
949
|
+
const pathSegments = 3;
|
|
950
|
+
|
|
951
|
+
function getComponentName(fiber) {
|
|
952
|
+
if (!fiber || !fiber.type) return null;
|
|
953
|
+
if (typeof fiber.type === 'string') return fiber.type;
|
|
954
|
+
return fiber.type.displayName || fiber.type.name || null;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function formatPath(pathArray) {
|
|
958
|
+
if (!shortPath || pathArray.length <= pathSegments) {
|
|
959
|
+
return pathArray.join(' > ');
|
|
960
|
+
}
|
|
961
|
+
return '... > ' + pathArray.slice(-pathSegments).join(' > ');
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function extractStyles(style) {
|
|
965
|
+
if (!style) return null;
|
|
966
|
+
const merged = Array.isArray(style)
|
|
967
|
+
? Object.assign({}, ...style.filter(Boolean).map(s => typeof s === 'object' ? s : {}))
|
|
968
|
+
: (typeof style === 'object' ? style : {});
|
|
969
|
+
return Object.keys(merged).length > 0 ? merged : null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function serializeValue(val, depth = 0) {
|
|
973
|
+
if (depth > 3) return '[Max depth]';
|
|
974
|
+
if (val === null) return null;
|
|
975
|
+
if (val === undefined) return undefined;
|
|
976
|
+
if (typeof val === 'function') return '[Function]';
|
|
977
|
+
if (typeof val !== 'object') return val;
|
|
978
|
+
if (Array.isArray(val)) {
|
|
979
|
+
if (val.length > 10) return '[Array(' + val.length + ')]';
|
|
980
|
+
return val.map(v => serializeValue(v, depth + 1));
|
|
981
|
+
}
|
|
982
|
+
// Object
|
|
983
|
+
const keys = Object.keys(val);
|
|
984
|
+
if (keys.length > 20) return '[Object(' + keys.length + ' keys)]';
|
|
985
|
+
const result = {};
|
|
986
|
+
for (const k of keys) {
|
|
987
|
+
result[k] = serializeValue(val[k], depth + 1);
|
|
988
|
+
}
|
|
989
|
+
return result;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function getChildTree(fiber, depth) {
|
|
993
|
+
if (!fiber || depth <= 0) return null;
|
|
994
|
+
const children = [];
|
|
995
|
+
let child = fiber?.child;
|
|
996
|
+
while (child && children.length < 30) {
|
|
997
|
+
const name = getComponentName(child);
|
|
998
|
+
if (name) {
|
|
999
|
+
if (depth === 1) {
|
|
1000
|
+
// Just names for depth 1
|
|
1001
|
+
children.push(name);
|
|
1002
|
+
} else {
|
|
1003
|
+
// Tree structure for depth > 1
|
|
1004
|
+
const nestedChildren = getChildTree(child, depth - 1);
|
|
1005
|
+
children.push(nestedChildren ? { component: name, children: nestedChildren } : name);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
child = child.sibling;
|
|
1009
|
+
}
|
|
1010
|
+
return children.length > 0 ? children : null;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const matches = [];
|
|
1014
|
+
|
|
1015
|
+
function findComponent(fiber, path) {
|
|
1016
|
+
if (!fiber) return;
|
|
1017
|
+
|
|
1018
|
+
const name = getComponentName(fiber);
|
|
1019
|
+
if (name === targetName) {
|
|
1020
|
+
matches.push({ fiber, path: [...path, name] });
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
let child = fiber.child;
|
|
1024
|
+
while (child) {
|
|
1025
|
+
const childName = getComponentName(child);
|
|
1026
|
+
findComponent(child, childName ? [...path, childName] : path);
|
|
1027
|
+
child = child.sibling;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
findComponent(roots[0].current, []);
|
|
1032
|
+
|
|
1033
|
+
if (matches.length === 0) {
|
|
1034
|
+
return { error: 'Component "' + targetName + '" not found in the component tree.' };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (targetIndex >= matches.length) {
|
|
1038
|
+
return { error: 'Component "' + targetName + '" found ' + matches.length + ' times, but index ' + targetIndex + ' requested.' };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const { fiber, path } = matches[targetIndex];
|
|
1042
|
+
|
|
1043
|
+
const result = {
|
|
1044
|
+
component: targetName,
|
|
1045
|
+
path: formatPath(path),
|
|
1046
|
+
instancesFound: matches.length,
|
|
1047
|
+
instanceIndex: targetIndex
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
// Props (excluding children)
|
|
1051
|
+
if (fiber.memoizedProps) {
|
|
1052
|
+
const props = {};
|
|
1053
|
+
for (const key of Object.keys(fiber.memoizedProps)) {
|
|
1054
|
+
if (key === 'children') continue;
|
|
1055
|
+
props[key] = serializeValue(fiber.memoizedProps[key]);
|
|
1056
|
+
}
|
|
1057
|
+
result.props = props;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Style separately for clarity
|
|
1061
|
+
if (fiber.memoizedProps?.style) {
|
|
1062
|
+
result.style = extractStyles(fiber.memoizedProps.style);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// State (for hooks, this is a linked list)
|
|
1066
|
+
if (includeState && fiber.memoizedState) {
|
|
1067
|
+
// Simplified hook value serialization
|
|
1068
|
+
function serializeHookValue(val, depth = 0) {
|
|
1069
|
+
if (depth > 2) return '[...]';
|
|
1070
|
+
if (val === null || val === undefined) return val;
|
|
1071
|
+
if (typeof val === 'function') return '[Function]';
|
|
1072
|
+
if (typeof val !== 'object') return val;
|
|
1073
|
+
// Skip React internal structures (effects, refs with destroy/create)
|
|
1074
|
+
if (val.create && val.destroy !== undefined) return '[Effect]';
|
|
1075
|
+
if (val.inst && val.deps) return '[Effect]';
|
|
1076
|
+
if (val.current !== undefined && Object.keys(val).length === 1) {
|
|
1077
|
+
// Ref object - just show current value
|
|
1078
|
+
return { current: serializeHookValue(val.current, depth + 1) };
|
|
1079
|
+
}
|
|
1080
|
+
if (Array.isArray(val)) {
|
|
1081
|
+
if (val.length > 5) return '[Array(' + val.length + ')]';
|
|
1082
|
+
return val.slice(0, 5).map(v => serializeHookValue(v, depth + 1));
|
|
1083
|
+
}
|
|
1084
|
+
const keys = Object.keys(val);
|
|
1085
|
+
if (keys.length > 10) return '[Object(' + keys.length + ' keys)]';
|
|
1086
|
+
const result = {};
|
|
1087
|
+
for (const k of keys.slice(0, 10)) {
|
|
1088
|
+
result[k] = serializeHookValue(val[k], depth + 1);
|
|
1089
|
+
}
|
|
1090
|
+
return result;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// For function components with hooks
|
|
1094
|
+
const states = [];
|
|
1095
|
+
let state = fiber.memoizedState;
|
|
1096
|
+
let hookIndex = 0;
|
|
1097
|
+
while (state && hookIndex < 20) {
|
|
1098
|
+
if (state.memoizedState !== undefined) {
|
|
1099
|
+
const hookVal = simplifyHooks
|
|
1100
|
+
? serializeHookValue(state.memoizedState)
|
|
1101
|
+
: serializeValue(state.memoizedState);
|
|
1102
|
+
// Skip effect hooks in simplified mode
|
|
1103
|
+
if (!simplifyHooks || (hookVal !== '[Effect]' && hookVal !== undefined)) {
|
|
1104
|
+
states.push({
|
|
1105
|
+
hookIndex,
|
|
1106
|
+
value: hookVal
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
state = state.next;
|
|
1111
|
+
hookIndex++;
|
|
1112
|
+
}
|
|
1113
|
+
if (states.length > 0) result.hooks = states;
|
|
1114
|
+
|
|
1115
|
+
// For class components, memoizedState is the state object directly
|
|
1116
|
+
if (states.length === 0 && typeof fiber.memoizedState === 'object') {
|
|
1117
|
+
result.state = serializeValue(fiber.memoizedState);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Children tree (depth controlled by childrenDepth)
|
|
1122
|
+
if (includeChildren) {
|
|
1123
|
+
result.children = getChildTree(fiber, childrenDepth);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return result;
|
|
1127
|
+
})()
|
|
1128
|
+
`;
|
|
1129
|
+
return executeInApp(expression, false);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Find all components matching a name pattern and return summary info.
|
|
1133
|
+
*/
|
|
1134
|
+
export async function findComponents(pattern, options = {}) {
|
|
1135
|
+
const { maxResults = 20, includeLayout = false, shortPath = true, summary = false, format = "tonl" } = options;
|
|
1136
|
+
const escapedPattern = pattern.replace(/'/g, "\\'").replace(/\\/g, "\\\\");
|
|
1137
|
+
const expression = `
|
|
1138
|
+
(function() {
|
|
1139
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
1140
|
+
if (!hook) return { error: 'React DevTools hook not found.' };
|
|
1141
|
+
|
|
1142
|
+
let roots = [];
|
|
1143
|
+
if (hook.getFiberRoots) {
|
|
1144
|
+
roots = [...(hook.getFiberRoots(1) || [])];
|
|
1145
|
+
}
|
|
1146
|
+
if (roots.length === 0 && hook.renderers) {
|
|
1147
|
+
for (const [id] of hook.renderers) {
|
|
1148
|
+
const r = hook.getFiberRoots ? [...(hook.getFiberRoots(id) || [])] : [];
|
|
1149
|
+
if (r.length > 0) { roots = r; break; }
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (roots.length === 0) return { error: 'No fiber roots found.' };
|
|
1153
|
+
|
|
1154
|
+
const pattern = '${escapedPattern}';
|
|
1155
|
+
const regex = new RegExp(pattern, 'i');
|
|
1156
|
+
const maxResults = ${maxResults};
|
|
1157
|
+
const includeLayout = ${includeLayout};
|
|
1158
|
+
const shortPath = ${shortPath};
|
|
1159
|
+
const summaryMode = ${summary};
|
|
1160
|
+
const pathSegments = 3;
|
|
1161
|
+
|
|
1162
|
+
function getComponentName(fiber) {
|
|
1163
|
+
if (!fiber || !fiber.type) return null;
|
|
1164
|
+
if (typeof fiber.type === 'string') return fiber.type;
|
|
1165
|
+
return fiber.type.displayName || fiber.type.name || null;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function formatPath(pathArray) {
|
|
1169
|
+
if (!shortPath || pathArray.length <= pathSegments) {
|
|
1170
|
+
return pathArray.join(' > ');
|
|
1171
|
+
}
|
|
1172
|
+
return '... > ' + pathArray.slice(-pathSegments).join(' > ');
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function extractLayoutStyles(style) {
|
|
1176
|
+
if (!style) return null;
|
|
1177
|
+
const merged = Array.isArray(style)
|
|
1178
|
+
? Object.assign({}, ...style.filter(Boolean).map(s => typeof s === 'object' ? s : {}))
|
|
1179
|
+
: (typeof style === 'object' ? style : {});
|
|
1180
|
+
|
|
1181
|
+
const layout = {};
|
|
1182
|
+
const keys = ['padding', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight',
|
|
1183
|
+
'paddingHorizontal', 'paddingVertical', 'margin', 'marginTop', 'marginBottom',
|
|
1184
|
+
'marginLeft', 'marginRight', 'marginHorizontal', 'marginVertical',
|
|
1185
|
+
'width', 'height', 'flex', 'flexDirection', 'justifyContent', 'alignItems'];
|
|
1186
|
+
for (const k of keys) {
|
|
1187
|
+
if (merged[k] !== undefined) layout[k] = merged[k];
|
|
1188
|
+
}
|
|
1189
|
+
return Object.keys(layout).length > 0 ? layout : null;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const results = [];
|
|
1193
|
+
|
|
1194
|
+
function search(fiber, path, depth) {
|
|
1195
|
+
if (!fiber || results.length >= maxResults) return;
|
|
1196
|
+
|
|
1197
|
+
const name = getComponentName(fiber);
|
|
1198
|
+
if (name && regex.test(name)) {
|
|
1199
|
+
const entry = {
|
|
1200
|
+
component: name,
|
|
1201
|
+
path: formatPath(path),
|
|
1202
|
+
depth
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
if (fiber.memoizedProps?.testID) entry.testID = fiber.memoizedProps.testID;
|
|
1206
|
+
if (fiber.key) entry.key = fiber.key;
|
|
1207
|
+
|
|
1208
|
+
if (includeLayout && fiber.memoizedProps?.style) {
|
|
1209
|
+
const layout = extractLayoutStyles(fiber.memoizedProps.style);
|
|
1210
|
+
if (layout) entry.layout = layout;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
results.push(entry);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
let child = fiber.child;
|
|
1217
|
+
while (child && results.length < maxResults) {
|
|
1218
|
+
const childName = getComponentName(child);
|
|
1219
|
+
search(child, childName ? [...path, childName] : path, depth + 1);
|
|
1220
|
+
child = child.sibling;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
search(roots[0].current, [], 0);
|
|
1225
|
+
|
|
1226
|
+
// Summary mode: just return counts by component name
|
|
1227
|
+
if (summaryMode) {
|
|
1228
|
+
const counts = {};
|
|
1229
|
+
for (const r of results) {
|
|
1230
|
+
counts[r.component] = (counts[r.component] || 0) + 1;
|
|
1231
|
+
}
|
|
1232
|
+
const sorted = Object.entries(counts)
|
|
1233
|
+
.sort((a, b) => b[1] - a[1])
|
|
1234
|
+
.map(([name, count]) => ({ component: name, count }));
|
|
1235
|
+
return {
|
|
1236
|
+
pattern,
|
|
1237
|
+
totalMatches: results.length,
|
|
1238
|
+
uniqueComponents: sorted.length,
|
|
1239
|
+
components: sorted
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return {
|
|
1244
|
+
pattern,
|
|
1245
|
+
found: results.length,
|
|
1246
|
+
components: results
|
|
1247
|
+
};
|
|
1248
|
+
})()
|
|
1249
|
+
`;
|
|
1250
|
+
const result = await executeInApp(expression, false);
|
|
1251
|
+
// Apply TONL formatting if requested
|
|
1252
|
+
if (format === "tonl" && result.success && result.result) {
|
|
1253
|
+
try {
|
|
1254
|
+
const parsed = JSON.parse(result.result);
|
|
1255
|
+
if (parsed.components) {
|
|
1256
|
+
if (parsed.totalMatches !== undefined) {
|
|
1257
|
+
// Summary mode
|
|
1258
|
+
const tonl = formatSummaryToTonl(parsed.components, parsed.totalMatches);
|
|
1259
|
+
return { success: true, result: `pattern: ${parsed.pattern}\n${tonl}` };
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
// Full list mode
|
|
1263
|
+
const tonl = formatFoundComponentsToTonl(parsed.components);
|
|
1264
|
+
return { success: true, result: `pattern: ${parsed.pattern}\nfound: ${parsed.found}\n${tonl}` };
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
// If parsing fails, return original result
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return result;
|
|
1273
|
+
}
|
|
1274
|
+
// ============================================================================
|
|
1275
|
+
// Press Element (invoke onPress via React Fiber Tree)
|
|
1276
|
+
// ============================================================================
|
|
1277
|
+
/**
|
|
1278
|
+
* Find a pressable element in the React fiber tree and invoke its onPress handler.
|
|
1279
|
+
* Matches by text content, testID, or component name.
|
|
1280
|
+
*/
|
|
1281
|
+
export async function pressElement(options) {
|
|
1282
|
+
const { text, testID, component, index = 0, maxTraversalDepth = 15 } = options;
|
|
1283
|
+
if (!text && !testID && !component) {
|
|
1284
|
+
return { success: false, error: "At least one of text, testID, or component must be provided." };
|
|
1285
|
+
}
|
|
1286
|
+
const esc = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1287
|
+
const textParam = text ? `'${esc(text)}'` : "null";
|
|
1288
|
+
const testIDParam = testID ? `'${esc(testID)}'` : "null";
|
|
1289
|
+
const componentParam = component ? `'${esc(component)}'` : "null";
|
|
1290
|
+
const expression = `
|
|
1291
|
+
(function() {
|
|
1292
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
1293
|
+
if (!hook) return { error: 'React DevTools hook not found. Ensure app is running in __DEV__ mode.' };
|
|
1294
|
+
|
|
1295
|
+
var roots = [];
|
|
1296
|
+
if (hook.getFiberRoots) {
|
|
1297
|
+
var renderers = hook.renderers;
|
|
1298
|
+
if (renderers) {
|
|
1299
|
+
for (var entry of renderers) {
|
|
1300
|
+
var r = Array.from(hook.getFiberRoots(entry[0]) || []);
|
|
1301
|
+
if (r.length > 0) { roots = r; break; }
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (roots.length === 0) return { error: 'No fiber roots found. Is a React Native app mounted?' };
|
|
1306
|
+
|
|
1307
|
+
var searchText = ${textParam};
|
|
1308
|
+
var searchTestID = ${testIDParam};
|
|
1309
|
+
var searchComponent = ${componentParam};
|
|
1310
|
+
var targetIndex = ${index};
|
|
1311
|
+
|
|
1312
|
+
function getComponentName(fiber) {
|
|
1313
|
+
if (!fiber || !fiber.type) return null;
|
|
1314
|
+
if (typeof fiber.type === 'string') return fiber.type;
|
|
1315
|
+
return fiber.type.displayName || fiber.type.name || null;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function extractText(fiber, depth) {
|
|
1319
|
+
if (!fiber || depth > 15) return '';
|
|
1320
|
+
var parts = [];
|
|
1321
|
+
var props = fiber.memoizedProps;
|
|
1322
|
+
if (props) {
|
|
1323
|
+
var ch = props.children;
|
|
1324
|
+
if (typeof ch === 'string') parts.push(ch);
|
|
1325
|
+
else if (typeof ch === 'number') parts.push(String(ch));
|
|
1326
|
+
else if (Array.isArray(ch)) {
|
|
1327
|
+
for (var i = 0; i < ch.length; i++) {
|
|
1328
|
+
if (typeof ch[i] === 'string') parts.push(ch[i]);
|
|
1329
|
+
else if (typeof ch[i] === 'number') parts.push(String(ch[i]));
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
var child = fiber.child;
|
|
1334
|
+
while (child) {
|
|
1335
|
+
parts.push(extractText(child, depth + 1));
|
|
1336
|
+
child = child.sibling;
|
|
1337
|
+
}
|
|
1338
|
+
return parts.join('');
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
var matches = [];
|
|
1342
|
+
|
|
1343
|
+
function walkFiber(fiber, path) {
|
|
1344
|
+
if (!fiber) return;
|
|
1345
|
+
var name = getComponentName(fiber);
|
|
1346
|
+
var props = fiber.memoizedProps;
|
|
1347
|
+
|
|
1348
|
+
// Skip off-screen subtrees — React Navigation marks hidden screens with aria-hidden
|
|
1349
|
+
if (name === 'RNSScreen' && props && props['aria-hidden'] === true) return;
|
|
1350
|
+
|
|
1351
|
+
var isPressable = props && typeof props.onPress === 'function';
|
|
1352
|
+
var isInput = !isPressable && props && (typeof props.onChangeText === 'function' || typeof props.onFocus === 'function');
|
|
1353
|
+
|
|
1354
|
+
if (isPressable || isInput) {
|
|
1355
|
+
var text = '';
|
|
1356
|
+
if (isPressable) {
|
|
1357
|
+
text = extractText(fiber, 0);
|
|
1358
|
+
} else {
|
|
1359
|
+
// For inputs: use child text, value, defaultValue, or placeholder
|
|
1360
|
+
var val = typeof props.value === 'string' ? props.value : '';
|
|
1361
|
+
var defVal = typeof props.defaultValue === 'string' ? props.defaultValue : '';
|
|
1362
|
+
var ph = typeof props.placeholder === 'string' ? props.placeholder : '';
|
|
1363
|
+
text = extractText(fiber, 0) || val || defVal || ph;
|
|
1364
|
+
}
|
|
1365
|
+
var tid = props.testID || props.nativeID || null;
|
|
1366
|
+
var matched = true;
|
|
1367
|
+
|
|
1368
|
+
if (searchText !== null) {
|
|
1369
|
+
matched = matched && text.toLowerCase().indexOf(searchText.toLowerCase()) !== -1;
|
|
1370
|
+
}
|
|
1371
|
+
if (searchTestID !== null) {
|
|
1372
|
+
matched = matched && (tid === searchTestID);
|
|
1373
|
+
}
|
|
1374
|
+
if (searchComponent !== null) {
|
|
1375
|
+
matched = matched && (name || '').toLowerCase().indexOf(searchComponent.toLowerCase()) !== -1;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
if (matched) {
|
|
1379
|
+
matches.push({
|
|
1380
|
+
fiber: fiber,
|
|
1381
|
+
name: name || '(anonymous)',
|
|
1382
|
+
text: text.substring(0, 100),
|
|
1383
|
+
testID: tid,
|
|
1384
|
+
path: path.join(' > '),
|
|
1385
|
+
isInput: isInput
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
var child = fiber.child;
|
|
1391
|
+
while (child) {
|
|
1392
|
+
var childName = getComponentName(child);
|
|
1393
|
+
walkFiber(child, childName ? path.concat([childName]) : path);
|
|
1394
|
+
child = child.sibling;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
for (var ri = 0; ri < roots.length; ri++) {
|
|
1399
|
+
walkFiber(roots[ri].current, []);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// Phase 2: If searching by component and no match found,
|
|
1403
|
+
// look for the component by name and walk UP to find nearest pressable ancestor
|
|
1404
|
+
if (matches.length === 0 && searchComponent !== null) {
|
|
1405
|
+
var componentCandidates = [];
|
|
1406
|
+
|
|
1407
|
+
function findByName(fiber, path) {
|
|
1408
|
+
if (!fiber) return;
|
|
1409
|
+
var name = getComponentName(fiber);
|
|
1410
|
+
var props = fiber.memoizedProps;
|
|
1411
|
+
if (name === 'RNSScreen' && props && props['aria-hidden'] === true) return;
|
|
1412
|
+
|
|
1413
|
+
if (name && name.toLowerCase().indexOf(searchComponent.toLowerCase()) !== -1) {
|
|
1414
|
+
componentCandidates.push({ fiber: fiber, name: name, path: path.join(' > ') });
|
|
1415
|
+
}
|
|
1416
|
+
var child = fiber.child;
|
|
1417
|
+
while (child) {
|
|
1418
|
+
var childName = getComponentName(child);
|
|
1419
|
+
findByName(child, childName ? path.concat([childName]) : path);
|
|
1420
|
+
child = child.sibling;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
for (var ri2 = 0; ri2 < roots.length; ri2++) {
|
|
1425
|
+
findByName(roots[ri2].current, []);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// For each candidate, walk up to find pressable ancestor
|
|
1429
|
+
var maxDepth = ${maxTraversalDepth};
|
|
1430
|
+
for (var ci = 0; ci < componentCandidates.length; ci++) {
|
|
1431
|
+
var candidate = componentCandidates[ci];
|
|
1432
|
+
var parent = candidate.fiber.return;
|
|
1433
|
+
var depth = 0;
|
|
1434
|
+
while (parent && depth < maxDepth) {
|
|
1435
|
+
var parentProps = parent.memoizedProps;
|
|
1436
|
+
if (parentProps && typeof parentProps.onPress === 'function') {
|
|
1437
|
+
var text = extractText(parent, 0);
|
|
1438
|
+
matches.push({
|
|
1439
|
+
fiber: parent,
|
|
1440
|
+
name: candidate.name,
|
|
1441
|
+
text: text.substring(0, 100),
|
|
1442
|
+
testID: parentProps.testID || parentProps.nativeID || null,
|
|
1443
|
+
path: candidate.path
|
|
1444
|
+
});
|
|
1445
|
+
break;
|
|
1446
|
+
}
|
|
1447
|
+
parent = parent.return;
|
|
1448
|
+
depth++;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (matches.length === 0) {
|
|
1454
|
+
var criteria = [];
|
|
1455
|
+
if (searchText !== null) criteria.push('text="' + searchText + '"');
|
|
1456
|
+
if (searchTestID !== null) criteria.push('testID="' + searchTestID + '"');
|
|
1457
|
+
if (searchComponent !== null) criteria.push('component="' + searchComponent + '"');
|
|
1458
|
+
return { error: 'No pressable or focusable elements found matching: ' + criteria.join(', ') };
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (targetIndex >= matches.length) {
|
|
1462
|
+
return {
|
|
1463
|
+
error: 'Found ' + matches.length + ' match(es) but index ' + targetIndex + ' requested (0-based). Use index 0-' + (matches.length - 1) + '.',
|
|
1464
|
+
matches: matches.map(function(m, i) {
|
|
1465
|
+
return { index: i, component: m.name, text: m.text, testID: m.testID };
|
|
1466
|
+
})
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
var target = matches[targetIndex];
|
|
1471
|
+
try {
|
|
1472
|
+
if (target.isInput) {
|
|
1473
|
+
// Input elements can't be pressed via fiber — return match info
|
|
1474
|
+
// so the orchestrator can fall through to accessibility/coordinate tap
|
|
1475
|
+
var result = {
|
|
1476
|
+
success: true,
|
|
1477
|
+
pressed: target.name,
|
|
1478
|
+
matchIndex: targetIndex,
|
|
1479
|
+
totalMatches: matches.length,
|
|
1480
|
+
text: target.text,
|
|
1481
|
+
testID: target.testID,
|
|
1482
|
+
path: target.path,
|
|
1483
|
+
isInput: true,
|
|
1484
|
+
needsNativeTap: true
|
|
1485
|
+
};
|
|
1486
|
+
if (matches.length > 1) {
|
|
1487
|
+
result.allMatches = matches.map(function(m, i) {
|
|
1488
|
+
return { index: i, component: m.name, text: m.text, testID: m.testID };
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
return result;
|
|
1492
|
+
}
|
|
1493
|
+
target.fiber.memoizedProps.onPress();
|
|
1494
|
+
var result = {
|
|
1495
|
+
success: true,
|
|
1496
|
+
pressed: target.name,
|
|
1497
|
+
matchIndex: targetIndex,
|
|
1498
|
+
totalMatches: matches.length,
|
|
1499
|
+
text: target.text,
|
|
1500
|
+
testID: target.testID,
|
|
1501
|
+
path: target.path
|
|
1502
|
+
};
|
|
1503
|
+
if (matches.length > 1) {
|
|
1504
|
+
result.allMatches = matches.map(function(m, i) {
|
|
1505
|
+
return { index: i, component: m.name, text: m.text, testID: m.testID };
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
return result;
|
|
1509
|
+
} catch (e) {
|
|
1510
|
+
return { error: 'Handler threw: ' + (e.message || String(e)), component: target.name };
|
|
1511
|
+
}
|
|
1512
|
+
})()
|
|
1513
|
+
`;
|
|
1514
|
+
return executeInApp(expression, false);
|
|
1515
|
+
}
|
|
1516
|
+
// ============================================================================
|
|
1517
|
+
// Coordinate-Based Element Inspection (via DevTools Inspector API)
|
|
1518
|
+
// ============================================================================
|
|
1519
|
+
/**
|
|
1520
|
+
* Toggle the Element Inspector via DevSettings native module.
|
|
1521
|
+
* This enables the inspector overlay programmatically.
|
|
1522
|
+
*/
|
|
1523
|
+
export async function toggleElementInspector() {
|
|
1524
|
+
const expression = `
|
|
1525
|
+
(function() {
|
|
1526
|
+
const ds = globalThis.nativeModuleProxy?.DevSettings;
|
|
1527
|
+
if (!ds) return { error: 'DevSettings not available' };
|
|
1528
|
+
|
|
1529
|
+
const proto = Object.getPrototypeOf(ds);
|
|
1530
|
+
if (!proto || typeof proto.toggleElementInspector !== 'function') {
|
|
1531
|
+
return { error: 'toggleElementInspector not found' };
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
try {
|
|
1535
|
+
proto.toggleElementInspector.call(ds);
|
|
1536
|
+
return { success: true, message: 'Element Inspector toggled' };
|
|
1537
|
+
} catch (e) {
|
|
1538
|
+
return { error: 'Failed to toggle: ' + e.message };
|
|
1539
|
+
}
|
|
1540
|
+
})()
|
|
1541
|
+
`;
|
|
1542
|
+
return executeInApp(expression, false);
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Check if the Element Inspector overlay is currently active.
|
|
1546
|
+
*/
|
|
1547
|
+
export async function isInspectorActive() {
|
|
1548
|
+
const expression = `
|
|
1549
|
+
(function() {
|
|
1550
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
1551
|
+
if (!hook) return false;
|
|
1552
|
+
|
|
1553
|
+
let roots = [...(hook.getFiberRoots?.(1) || [])];
|
|
1554
|
+
if (roots.length === 0) {
|
|
1555
|
+
for (const [id] of (hook.renderers || [])) {
|
|
1556
|
+
roots = [...(hook.getFiberRoots?.(id) || [])];
|
|
1557
|
+
if (roots.length > 0) break;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
if (roots.length === 0) return false;
|
|
1561
|
+
|
|
1562
|
+
function findComponent(fiber, targetName, depth = 0) {
|
|
1563
|
+
if (!fiber || depth > 100) return null;
|
|
1564
|
+
const name = fiber.type?.displayName || fiber.type?.name;
|
|
1565
|
+
if (name === targetName) return fiber;
|
|
1566
|
+
let child = fiber.child;
|
|
1567
|
+
while (child) {
|
|
1568
|
+
const found = findComponent(child, targetName, depth + 1);
|
|
1569
|
+
if (found) return found;
|
|
1570
|
+
child = child.sibling;
|
|
1571
|
+
}
|
|
1572
|
+
return null;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
return !!findComponent(roots[0].current, 'InspectorPanel');
|
|
1576
|
+
})()
|
|
1577
|
+
`;
|
|
1578
|
+
const result = await executeInApp(expression, false);
|
|
1579
|
+
if (result.success && result.result) {
|
|
1580
|
+
return result.result === "true";
|
|
1581
|
+
}
|
|
1582
|
+
return false;
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Get the currently selected element from the Element Inspector overlay.
|
|
1586
|
+
* This reads the InspectorPanel component's props to get the hierarchy, frame, and style.
|
|
1587
|
+
* Requires the Element Inspector to be enabled and an element to be selected.
|
|
1588
|
+
*/
|
|
1589
|
+
export async function getInspectorSelection() {
|
|
1590
|
+
const expression = `
|
|
1591
|
+
(function() {
|
|
1592
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
1593
|
+
if (!hook) return { error: 'React DevTools hook not available.' };
|
|
1594
|
+
|
|
1595
|
+
// Find fiber roots
|
|
1596
|
+
let roots = [...(hook.getFiberRoots?.(1) || [])];
|
|
1597
|
+
if (roots.length === 0) {
|
|
1598
|
+
for (const [id] of (hook.renderers || [])) {
|
|
1599
|
+
roots = [...(hook.getFiberRoots?.(id) || [])];
|
|
1600
|
+
if (roots.length > 0) break;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if (roots.length === 0) return { error: 'No fiber roots found.' };
|
|
1604
|
+
|
|
1605
|
+
// Find InspectorPanel component
|
|
1606
|
+
function findComponent(fiber, targetName, depth = 0) {
|
|
1607
|
+
if (!fiber || depth > 100) return null;
|
|
1608
|
+
const name = fiber.type?.displayName || fiber.type?.name;
|
|
1609
|
+
if (name === targetName) return fiber;
|
|
1610
|
+
let child = fiber.child;
|
|
1611
|
+
while (child) {
|
|
1612
|
+
const found = findComponent(child, targetName, depth + 1);
|
|
1613
|
+
if (found) return found;
|
|
1614
|
+
child = child.sibling;
|
|
1615
|
+
}
|
|
1616
|
+
return null;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const panelFiber = findComponent(roots[0].current, 'InspectorPanel');
|
|
1620
|
+
if (!panelFiber) {
|
|
1621
|
+
return {
|
|
1622
|
+
error: 'Element Inspector is not active.',
|
|
1623
|
+
hint: 'Use toggle_element_inspector to enable the inspector, then tap an element to select it.'
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const props = panelFiber.memoizedProps;
|
|
1628
|
+
if (!props.hierarchy || props.hierarchy.length === 0) {
|
|
1629
|
+
return {
|
|
1630
|
+
error: 'No element selected.',
|
|
1631
|
+
hint: 'Tap on an element in the app to select it for inspection.'
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// Build the path from hierarchy
|
|
1636
|
+
const path = props.hierarchy.map(h => h.name).join(' > ');
|
|
1637
|
+
const element = props.hierarchy[props.hierarchy.length - 1]?.name || 'Unknown';
|
|
1638
|
+
|
|
1639
|
+
// Extract style info
|
|
1640
|
+
let style = {};
|
|
1641
|
+
if (props.inspected?.style) {
|
|
1642
|
+
const styles = Array.isArray(props.inspected.style)
|
|
1643
|
+
? props.inspected.style
|
|
1644
|
+
: [props.inspected.style];
|
|
1645
|
+
for (const s of styles) {
|
|
1646
|
+
if (s && typeof s === 'object') {
|
|
1647
|
+
Object.assign(style, s);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
return {
|
|
1653
|
+
element,
|
|
1654
|
+
path,
|
|
1655
|
+
frame: props.inspected?.frame || null,
|
|
1656
|
+
style: Object.keys(style).length > 0 ? style : null,
|
|
1657
|
+
selection: props.selection,
|
|
1658
|
+
hierarchyLength: props.hierarchy.length
|
|
1659
|
+
};
|
|
1660
|
+
})()
|
|
1661
|
+
`;
|
|
1662
|
+
return executeInApp(expression, false);
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Inspect the React component at a specific (x, y) coordinate.
|
|
1666
|
+
*
|
|
1667
|
+
* Works on both Paper and Fabric (New Architecture). Uses a two-step approach
|
|
1668
|
+
* because measureInWindow callbacks fire in a future native event loop tick
|
|
1669
|
+
* (not microtasks), so awaitPromise cannot be used to collect them:
|
|
1670
|
+
*
|
|
1671
|
+
* Step 1 — dispatch: walk the fiber tree, call measureInWindow on each host
|
|
1672
|
+
* component, store fiber refs and results in app globals.
|
|
1673
|
+
* Step 2 — resolve (after 300ms): read the globals, hit-test against target
|
|
1674
|
+
* coordinates, return the innermost matching React component.
|
|
1675
|
+
*/
|
|
1676
|
+
export async function inspectAtPoint(x, y, options = {}) {
|
|
1677
|
+
const { includeProps = true, includeFrame = true } = options;
|
|
1678
|
+
// --- Step 1: walk fiber tree + dispatch measureInWindow calls ---
|
|
1679
|
+
const dispatchExpression = `
|
|
1680
|
+
(function() {
|
|
1681
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
1682
|
+
if (!hook) return { error: 'React DevTools hook not available. Make sure you are running a development build.' };
|
|
1683
|
+
|
|
1684
|
+
var roots = [];
|
|
1685
|
+
if (hook.getFiberRoots) {
|
|
1686
|
+
try { roots = Array.from(hook.getFiberRoots(1) || []); } catch(e) {}
|
|
1687
|
+
}
|
|
1688
|
+
if (roots.length === 0 && hook.renderers) {
|
|
1689
|
+
for (var entry of hook.renderers) {
|
|
1690
|
+
try {
|
|
1691
|
+
var r = Array.from(hook.getFiberRoots ? (hook.getFiberRoots(entry[0]) || []) : []);
|
|
1692
|
+
if (r.length > 0) { roots = r; break; }
|
|
1693
|
+
} catch(e) {}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
if (roots.length === 0) return { error: 'No fiber roots found. The app may not have rendered yet.' };
|
|
1697
|
+
|
|
1698
|
+
// Paper: measureInWindow is on stateNode directly.
|
|
1699
|
+
// Fabric: measureInWindow is on stateNode.canonical.publicInstance.
|
|
1700
|
+
function getMeasurable(fiber) {
|
|
1701
|
+
var sn = fiber.stateNode;
|
|
1702
|
+
if (!sn) return null;
|
|
1703
|
+
if (typeof sn.measureInWindow === 'function') return sn;
|
|
1704
|
+
if (sn.canonical && sn.canonical.publicInstance &&
|
|
1705
|
+
typeof sn.canonical.publicInstance.measureInWindow === 'function') {
|
|
1706
|
+
return sn.canonical.publicInstance;
|
|
1707
|
+
}
|
|
1708
|
+
return null;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
var hostFibers = [];
|
|
1712
|
+
function walkFibers(fiber, depth) {
|
|
1713
|
+
var cur = fiber;
|
|
1714
|
+
while (cur) {
|
|
1715
|
+
if (hostFibers.length >= 500) return;
|
|
1716
|
+
if (typeof cur.type === 'string' && getMeasurable(cur)) hostFibers.push(cur);
|
|
1717
|
+
if (cur.child && depth < 250) walkFibers(cur.child, depth + 1);
|
|
1718
|
+
cur = cur.sibling;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
for (var root of roots) { walkFibers(root.current, 0); }
|
|
1722
|
+
|
|
1723
|
+
if (hostFibers.length === 0) return { error: 'No measurable host components found. App may not be fully rendered.' };
|
|
1724
|
+
|
|
1725
|
+
globalThis.__inspectFibers = hostFibers;
|
|
1726
|
+
globalThis.__inspectMeasurements = new Array(hostFibers.length).fill(null);
|
|
1727
|
+
|
|
1728
|
+
hostFibers.forEach(function(fiber, i) {
|
|
1729
|
+
try {
|
|
1730
|
+
getMeasurable(fiber).measureInWindow(function(fx, fy, fw, fh) {
|
|
1731
|
+
globalThis.__inspectMeasurements[i] = { x: fx, y: fy, width: fw, height: fh };
|
|
1732
|
+
});
|
|
1733
|
+
} catch(e) {}
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
return { count: hostFibers.length };
|
|
1737
|
+
})()
|
|
1738
|
+
`;
|
|
1739
|
+
const dispatchResult = await executeInApp(dispatchExpression, false);
|
|
1740
|
+
if (!dispatchResult.success)
|
|
1741
|
+
return dispatchResult;
|
|
1742
|
+
try {
|
|
1743
|
+
const parsed = JSON.parse(dispatchResult.result || "{}");
|
|
1744
|
+
if (parsed.error)
|
|
1745
|
+
return { success: false, error: parsed.error };
|
|
1746
|
+
}
|
|
1747
|
+
catch {
|
|
1748
|
+
/* ignore parse errors */
|
|
1749
|
+
}
|
|
1750
|
+
// Wait for native measureInWindow callbacks to fire
|
|
1751
|
+
await delay(300);
|
|
1752
|
+
// --- Step 2: read measurements, hit-test, return result ---
|
|
1753
|
+
const resolveExpression = `
|
|
1754
|
+
(function() {
|
|
1755
|
+
var fibers = globalThis.__inspectFibers;
|
|
1756
|
+
var measurements = globalThis.__inspectMeasurements;
|
|
1757
|
+
globalThis.__inspectFibers = null;
|
|
1758
|
+
globalThis.__inspectMeasurements = null;
|
|
1759
|
+
|
|
1760
|
+
if (!fibers || !measurements) return { error: 'No measurement data available. Run inspect_at_point again.' };
|
|
1761
|
+
|
|
1762
|
+
var targetX = ${x};
|
|
1763
|
+
var targetY = ${y};
|
|
1764
|
+
|
|
1765
|
+
var hits = [];
|
|
1766
|
+
for (var i = 0; i < measurements.length; i++) {
|
|
1767
|
+
var m = measurements[i];
|
|
1768
|
+
if (m && m.width > 0 && m.height > 0 &&
|
|
1769
|
+
targetX >= m.x && targetX <= m.x + m.width &&
|
|
1770
|
+
targetY >= m.y && targetY <= m.y + m.height) {
|
|
1771
|
+
hits.push({ fiber: fibers[i], x: m.x, y: m.y, width: m.width, height: m.height });
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
if (hits.length === 0) {
|
|
1776
|
+
return { point: { x: targetX, y: targetY }, error: 'No component found at this point. Coordinates may be outside the app bounds or over a native-only element.' };
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// Smallest area = innermost (most specific) component
|
|
1780
|
+
hits.sort(function(a, b) { return (a.width * a.height) - (b.width * b.height); });
|
|
1781
|
+
var best = hits[0];
|
|
1782
|
+
|
|
1783
|
+
// RN primitives and internal components to skip when surfacing the "element" name.
|
|
1784
|
+
// We want the nearest *custom* component, not a library wrapper.
|
|
1785
|
+
var RN_PRIMITIVES = /^(View|Text|Image|ScrollView|FlatList|SectionList|TextInput|TouchableOpacity|TouchableHighlight|TouchableNativeFeedback|TouchableWithoutFeedback|Pressable|Button|Switch|ActivityIndicator|Modal|SafeAreaView|KeyboardAvoidingView|Animated\\(.*|withAnimated.*|ForwardRef.*|memo\\(.*|Context\\.Consumer|Context\\.Provider|VirtualizedList.*|CellRenderer.*|FrameSizeProvider|MaybeScreenContainer|RCT.*|RNS.*|Navigation.*|Screen$|ScreenStack|ScreenContainer|ScreenContentWrapper|SceneView|DelayedFreeze|Freeze|Suspender|DebugContainer|StaticContainer|Expo.*|LinearGradient|ViewManagerAdapter_.*|Svg.*|Defs|Path|Rect|Circle|G|Line|Polygon|Polyline|Ellipse|ClipPath|GestureHandler.*|NativeViewGestureHandler|Reanimated.*|BottomTabNavigator|TabLayout|RouteNode|Route$|MaybeScreen|SafeAreaProvider.*|GestureDetector|PanGestureHandler|DropShadow|BlurView|MaskedView.*)$/;
|
|
1786
|
+
|
|
1787
|
+
function getNearestNamed(fiber, skipPrimitives) {
|
|
1788
|
+
var cur = fiber;
|
|
1789
|
+
var fallback = null;
|
|
1790
|
+
while (cur) {
|
|
1791
|
+
if (cur.type && typeof cur.type !== 'string') {
|
|
1792
|
+
var name = cur.type.displayName || cur.type.name;
|
|
1793
|
+
if (name) {
|
|
1794
|
+
if (!fallback) fallback = { name: name, fiber: cur };
|
|
1795
|
+
if (!skipPrimitives || !RN_PRIMITIVES.test(name)) {
|
|
1796
|
+
return { name: name, fiber: cur };
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
cur = cur.return;
|
|
1801
|
+
}
|
|
1802
|
+
return fallback;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function buildPath(fiber) {
|
|
1806
|
+
var path = [];
|
|
1807
|
+
var cur = fiber;
|
|
1808
|
+
while (cur) {
|
|
1809
|
+
if (cur.type) {
|
|
1810
|
+
var n = typeof cur.type === 'string'
|
|
1811
|
+
? cur.type
|
|
1812
|
+
: (cur.type.displayName || cur.type.name);
|
|
1813
|
+
if (n) path.unshift(n);
|
|
1814
|
+
}
|
|
1815
|
+
cur = cur.return;
|
|
1816
|
+
}
|
|
1817
|
+
return path.slice(-8).join(' > ');
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Find nearest custom component (skipping RN primitives) for the element name,
|
|
1821
|
+
// but fall back to the nearest named component if nothing custom is found.
|
|
1822
|
+
var named = getNearestNamed(best.fiber.return || best.fiber, true);
|
|
1823
|
+
var result = {
|
|
1824
|
+
point: { x: targetX, y: targetY },
|
|
1825
|
+
element: named ? named.name : best.fiber.type,
|
|
1826
|
+
nativeElement: best.fiber.type,
|
|
1827
|
+
path: buildPath(best.fiber)
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
if (${includeFrame}) {
|
|
1831
|
+
result.frame = { x: best.x, y: best.y, width: best.width, height: best.height };
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
if (${includeProps} && named && named.fiber.memoizedProps) {
|
|
1835
|
+
var props = {};
|
|
1836
|
+
var keys = Object.keys(named.fiber.memoizedProps);
|
|
1837
|
+
for (var i = 0; i < keys.length; i++) {
|
|
1838
|
+
var key = keys[i];
|
|
1839
|
+
if (key === 'children') continue;
|
|
1840
|
+
var val = named.fiber.memoizedProps[key];
|
|
1841
|
+
if (typeof val === 'function') {
|
|
1842
|
+
props[key] = '[Function]';
|
|
1843
|
+
} else if (typeof val === 'object' && val !== null) {
|
|
1844
|
+
try {
|
|
1845
|
+
var str = JSON.stringify(val);
|
|
1846
|
+
props[key] = str.length > 200
|
|
1847
|
+
? (Array.isArray(val) ? '[Array(' + val.length + ')]' : '[Object]')
|
|
1848
|
+
: val;
|
|
1849
|
+
} catch(e) {
|
|
1850
|
+
props[key] = '[Object]';
|
|
1851
|
+
}
|
|
1852
|
+
} else {
|
|
1853
|
+
props[key] = val;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
if (Object.keys(props).length > 0) result.props = props;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Hierarchy: custom-named component for each hit, deduped, innermost→outermost
|
|
1860
|
+
var hierarchy = [];
|
|
1861
|
+
for (var j = 0; j < Math.min(hits.length, 15); j++) {
|
|
1862
|
+
var n2 = getNearestNamed(hits[j].fiber.return, true) || getNearestNamed(hits[j].fiber, true);
|
|
1863
|
+
if (n2 && !hierarchy.some(function(h) { return h.name === n2.name; })) {
|
|
1864
|
+
hierarchy.push({
|
|
1865
|
+
name: n2.name,
|
|
1866
|
+
frame: { x: hits[j].x, y: hits[j].y, width: hits[j].width, height: hits[j].height }
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
if (hierarchy.length > 1) result.hierarchy = hierarchy;
|
|
1871
|
+
|
|
1872
|
+
return result;
|
|
1873
|
+
})()
|
|
1874
|
+
`;
|
|
1875
|
+
return executeInApp(resolveExpression, false);
|
|
1876
|
+
}
|
|
1877
|
+
//# sourceMappingURL=executor.js.map
|