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,963 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { connectedApps, pendingExecutions, getNextMessageId, logBuffer, networkBuffer, setActiveSimulatorUdid, clearActiveSimulatorIfSource, updateLastCDPMessageTime, getLastCDPMessageTime } from "./state.js";
|
|
3
|
+
import { mapConsoleType } from "./logs.js";
|
|
4
|
+
import { findSimulatorByName } from "./ios.js";
|
|
5
|
+
import { fetchDevices, selectMainDevice, scanMetroPorts } from "./metro.js";
|
|
6
|
+
import { DEFAULT_RECONNECTION_CONFIG, MIN_STABLE_CONNECTION_MS, initConnectionState, updateConnectionState, getConnectionState, recordConnectionGap, closeConnectionGap, saveConnectionMetadata, getConnectionMetadata, saveReconnectionTimer, cancelReconnectionTimer, calculateBackoffDelay, initContextHealth, markContextHealthy, markContextStale, getContextHealth, updateContextHealth, formatDuration, } from "./connectionState.js";
|
|
7
|
+
// Connection locks to prevent concurrent connection attempts to the same device
|
|
8
|
+
const connectionLocks = new Set();
|
|
9
|
+
// Suppress auto-reconnection for intentionally disconnected devices
|
|
10
|
+
const reconnectionSuppressed = new Set();
|
|
11
|
+
/**
|
|
12
|
+
* Suppress auto-reconnection for all current connections.
|
|
13
|
+
* Used by disconnect_metro to prevent close handlers from re-connecting.
|
|
14
|
+
*/
|
|
15
|
+
export function suppressReconnection() {
|
|
16
|
+
for (const key of connectedApps.keys()) {
|
|
17
|
+
reconnectionSuppressed.add(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Clear reconnection suppression (called when user explicitly reconnects via scan_metro).
|
|
22
|
+
*/
|
|
23
|
+
export function clearReconnectionSuppression() {
|
|
24
|
+
reconnectionSuppressed.clear();
|
|
25
|
+
}
|
|
26
|
+
const STALE_ACTIVITY_THRESHOLD_MS = 30_000;
|
|
27
|
+
const RECONNECT_SETTLE_MS = 500;
|
|
28
|
+
// Helper to find appKey from device info by searching connectedApps
|
|
29
|
+
function findAppKeyForDevice(device) {
|
|
30
|
+
for (const [key, app] of connectedApps.entries()) {
|
|
31
|
+
if (app.deviceInfo.id === device.id) {
|
|
32
|
+
return key;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// Helper to convert WebSocket readyState to readable name
|
|
38
|
+
function getWebSocketStateName(state) {
|
|
39
|
+
switch (state) {
|
|
40
|
+
case WebSocket.CONNECTING: return "CONNECTING";
|
|
41
|
+
case WebSocket.OPEN: return "OPEN";
|
|
42
|
+
case WebSocket.CLOSING: return "CLOSING";
|
|
43
|
+
case WebSocket.CLOSED: return "CLOSED";
|
|
44
|
+
default: return `UNKNOWN(${state})`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Format CDP RemoteObject to readable string
|
|
48
|
+
export function formatRemoteObject(result) {
|
|
49
|
+
if (result.type === "undefined") {
|
|
50
|
+
return "undefined";
|
|
51
|
+
}
|
|
52
|
+
if (result.subtype === "null") {
|
|
53
|
+
return "null";
|
|
54
|
+
}
|
|
55
|
+
// For objects/arrays with a value, stringify it
|
|
56
|
+
if (result.value !== undefined) {
|
|
57
|
+
if (typeof result.value === "object") {
|
|
58
|
+
return JSON.stringify(result.value, null, 2);
|
|
59
|
+
}
|
|
60
|
+
return String(result.value);
|
|
61
|
+
}
|
|
62
|
+
// Use description for complex objects
|
|
63
|
+
if (result.description) {
|
|
64
|
+
return result.description;
|
|
65
|
+
}
|
|
66
|
+
// Handle unserializable values (NaN, Infinity, etc.)
|
|
67
|
+
if (result.unserializableValue) {
|
|
68
|
+
return result.unserializableValue;
|
|
69
|
+
}
|
|
70
|
+
return `[${result.type}${result.subtype ? ` ${result.subtype}` : ""}]`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Extract a clean, informative error message from CDP exception details
|
|
74
|
+
* Handles various error formats from Hermes and other JS engines
|
|
75
|
+
*/
|
|
76
|
+
function extractExceptionMessage(exceptionDetails) {
|
|
77
|
+
const parts = [];
|
|
78
|
+
// Get the exception object if available
|
|
79
|
+
const exc = exceptionDetails.exception;
|
|
80
|
+
if (exc) {
|
|
81
|
+
// For error objects, className tells us the error type (ReferenceError, TypeError, etc.)
|
|
82
|
+
const errorType = exc.className || (exc.subtype === 'error' ? 'Error' : '');
|
|
83
|
+
// The description usually contains "ErrorType: message" or full stack trace
|
|
84
|
+
// We want to extract just the first line (the actual error message)
|
|
85
|
+
if (exc.description) {
|
|
86
|
+
const firstLine = exc.description.split('\n')[0].trim();
|
|
87
|
+
// If description already includes the error type, use it directly
|
|
88
|
+
if (firstLine.includes(':')) {
|
|
89
|
+
parts.push(firstLine);
|
|
90
|
+
}
|
|
91
|
+
else if (errorType) {
|
|
92
|
+
// Combine error type with description
|
|
93
|
+
parts.push(`${errorType}: ${firstLine}`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
parts.push(firstLine);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (exc.value !== undefined) {
|
|
100
|
+
// For primitive exceptions (throw "string" or throw 123)
|
|
101
|
+
const valueStr = typeof exc.value === 'string' ? exc.value : JSON.stringify(exc.value);
|
|
102
|
+
if (errorType) {
|
|
103
|
+
parts.push(`${errorType}: ${valueStr}`);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
parts.push(valueStr);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else if (errorType) {
|
|
110
|
+
// Just the error type, no message
|
|
111
|
+
parts.push(errorType);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Fall back to exceptionDetails.text if we couldn't extract from exception object
|
|
115
|
+
// But avoid just "Uncaught" which is not helpful
|
|
116
|
+
if (parts.length === 0) {
|
|
117
|
+
const text = exceptionDetails.text;
|
|
118
|
+
if (text && text.toLowerCase() !== 'uncaught') {
|
|
119
|
+
parts.push(text);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Add location info for syntax/compilation errors (helps identify the problem)
|
|
123
|
+
if (exceptionDetails.lineNumber !== undefined && exceptionDetails.columnNumber !== undefined) {
|
|
124
|
+
// Only add location if it's meaningful (not 0:0 which is often just wrapper)
|
|
125
|
+
if (exceptionDetails.lineNumber > 0 || exceptionDetails.columnNumber > 0) {
|
|
126
|
+
parts.push(`at line ${exceptionDetails.lineNumber}:${exceptionDetails.columnNumber}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// If we still have nothing, provide a generic message
|
|
130
|
+
if (parts.length === 0) {
|
|
131
|
+
return 'JavaScript execution failed (no error details available)';
|
|
132
|
+
}
|
|
133
|
+
return parts.join(' ');
|
|
134
|
+
}
|
|
135
|
+
// Format a CDP object preview recursively
|
|
136
|
+
function formatPreview(preview) {
|
|
137
|
+
const isArray = preview.subtype === "array";
|
|
138
|
+
const props = preview.properties || [];
|
|
139
|
+
const formatted = props.map((p) => {
|
|
140
|
+
let value;
|
|
141
|
+
if (p.valuePreview) {
|
|
142
|
+
value = formatPreview(p.valuePreview);
|
|
143
|
+
}
|
|
144
|
+
else if (p.subtype === "null") {
|
|
145
|
+
value = "null";
|
|
146
|
+
}
|
|
147
|
+
else if (p.type === "string") {
|
|
148
|
+
value = `"${p.value}"`;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
value = p.value ?? "undefined";
|
|
152
|
+
}
|
|
153
|
+
return isArray ? value : `${p.name}: ${value}`;
|
|
154
|
+
});
|
|
155
|
+
const overflow = preview.overflow ? ", ..." : "";
|
|
156
|
+
return isArray
|
|
157
|
+
? `[${formatted.join(", ")}${overflow}]`
|
|
158
|
+
: `{${formatted.join(", ")}${overflow}}`;
|
|
159
|
+
}
|
|
160
|
+
// Format a single CDP console argument (sync — without object resolution)
|
|
161
|
+
function formatConsoleArg(arg) {
|
|
162
|
+
// Primitives
|
|
163
|
+
if (arg.type === "string" || arg.type === "number" || arg.type === "boolean") {
|
|
164
|
+
return String(arg.value);
|
|
165
|
+
}
|
|
166
|
+
// Objects/arrays with preview — expand inline
|
|
167
|
+
if (arg.preview?.properties) {
|
|
168
|
+
return formatPreview(arg.preview);
|
|
169
|
+
}
|
|
170
|
+
// Raw value (e.g. null sent as value)
|
|
171
|
+
if (arg.value !== undefined) {
|
|
172
|
+
return JSON.stringify(arg.value);
|
|
173
|
+
}
|
|
174
|
+
// Description fallback (functions, symbols, errors without preview)
|
|
175
|
+
if (arg.description) {
|
|
176
|
+
return arg.description;
|
|
177
|
+
}
|
|
178
|
+
return "[object]";
|
|
179
|
+
}
|
|
180
|
+
// Fetch object properties via CDP Runtime.getProperties
|
|
181
|
+
function fetchObjectProperties(ws, objectId, depth = 2) {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
const msgId = getNextMessageId();
|
|
184
|
+
const timeout = setTimeout(() => {
|
|
185
|
+
resolve("Object"); // Fallback on timeout
|
|
186
|
+
}, 3000);
|
|
187
|
+
const handler = (data) => {
|
|
188
|
+
try {
|
|
189
|
+
const response = JSON.parse(data.toString());
|
|
190
|
+
if (response.id === msgId) {
|
|
191
|
+
clearTimeout(timeout);
|
|
192
|
+
ws.removeListener("message", handler);
|
|
193
|
+
if (response.result?.result) {
|
|
194
|
+
resolve(formatCDPProperties(ws, response.result.result, depth));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
resolve("Object");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Ignore parse errors
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
ws.on("message", handler);
|
|
206
|
+
ws.send(JSON.stringify({
|
|
207
|
+
id: msgId,
|
|
208
|
+
method: "Runtime.getProperties",
|
|
209
|
+
params: { objectId, ownProperties: true, generatePreview: true }
|
|
210
|
+
}));
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// Format CDP property descriptors into a readable string
|
|
214
|
+
async function formatCDPProperties(ws, properties, depth) {
|
|
215
|
+
const parts = [];
|
|
216
|
+
let isArrayLike = false;
|
|
217
|
+
// Detect arrays by checking for numeric keys and "length" property
|
|
218
|
+
const propNames = properties.filter((p) => !p.isAccessor && p.name !== "__proto__").map((p) => p.name);
|
|
219
|
+
const hasLength = propNames.includes("length");
|
|
220
|
+
const numericKeys = propNames.filter((n) => /^\d+$/.test(n));
|
|
221
|
+
if (hasLength && numericKeys.length > 0 && numericKeys.length >= propNames.length - 1) {
|
|
222
|
+
isArrayLike = true;
|
|
223
|
+
}
|
|
224
|
+
const filteredProps = properties.filter((p) => !p.isAccessor && p.name !== "__proto__" && (!isArrayLike || p.name !== "length"));
|
|
225
|
+
for (const prop of filteredProps) {
|
|
226
|
+
const val = prop.value;
|
|
227
|
+
if (!val)
|
|
228
|
+
continue;
|
|
229
|
+
const formatted = await formatPropertyValue(ws, val, depth - 1);
|
|
230
|
+
if (isArrayLike) {
|
|
231
|
+
parts.push(formatted);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
parts.push(`${prop.name}: ${formatted}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return isArrayLike
|
|
238
|
+
? `[${parts.join(", ")}]`
|
|
239
|
+
: `{${parts.join(", ")}}`;
|
|
240
|
+
}
|
|
241
|
+
// Format a single property value
|
|
242
|
+
async function formatPropertyValue(ws, val, remainingDepth) {
|
|
243
|
+
const type = val.type;
|
|
244
|
+
const subtype = val.subtype;
|
|
245
|
+
if (subtype === "null")
|
|
246
|
+
return "null";
|
|
247
|
+
if (type === "undefined")
|
|
248
|
+
return "undefined";
|
|
249
|
+
if (type === "string")
|
|
250
|
+
return `"${val.value}"`;
|
|
251
|
+
if (type === "number" || type === "boolean")
|
|
252
|
+
return String(val.value);
|
|
253
|
+
if (type === "function")
|
|
254
|
+
return "[Function]";
|
|
255
|
+
// Nested object — recurse if depth allows
|
|
256
|
+
if (type === "object" && val.objectId && remainingDepth > 0) {
|
|
257
|
+
return fetchObjectProperties(ws, val.objectId, remainingDepth);
|
|
258
|
+
}
|
|
259
|
+
// Object but no depth left — use description
|
|
260
|
+
if (type === "object") {
|
|
261
|
+
return val.description || "[Object]";
|
|
262
|
+
}
|
|
263
|
+
if (val.value !== undefined)
|
|
264
|
+
return JSON.stringify(val.value);
|
|
265
|
+
if (val.description)
|
|
266
|
+
return val.description;
|
|
267
|
+
return `[${type}]`;
|
|
268
|
+
}
|
|
269
|
+
// Resolve all object args in a console message, returning formatted text
|
|
270
|
+
async function resolveConsoleArgs(ws, args) {
|
|
271
|
+
const parts = await Promise.all(args.map(async (arg) => {
|
|
272
|
+
// Primitives — format synchronously
|
|
273
|
+
if (arg.type === "string" || arg.type === "number" || arg.type === "boolean") {
|
|
274
|
+
return String(arg.value);
|
|
275
|
+
}
|
|
276
|
+
// Objects/arrays with preview — expand inline
|
|
277
|
+
if (arg.preview?.properties) {
|
|
278
|
+
return formatPreview(arg.preview);
|
|
279
|
+
}
|
|
280
|
+
// Object with objectId — fetch properties via CDP
|
|
281
|
+
if (arg.type === "object" && arg.objectId) {
|
|
282
|
+
return fetchObjectProperties(ws, arg.objectId);
|
|
283
|
+
}
|
|
284
|
+
// Raw value
|
|
285
|
+
if (arg.value !== undefined) {
|
|
286
|
+
return JSON.stringify(arg.value);
|
|
287
|
+
}
|
|
288
|
+
// Description fallback
|
|
289
|
+
if (arg.description) {
|
|
290
|
+
return arg.description;
|
|
291
|
+
}
|
|
292
|
+
return "[object]";
|
|
293
|
+
}));
|
|
294
|
+
return parts.join(" ");
|
|
295
|
+
}
|
|
296
|
+
// Handle CDP messages
|
|
297
|
+
export function handleCDPMessage(message, _device, ws) {
|
|
298
|
+
// Track last CDP activity for connection liveness detection
|
|
299
|
+
updateLastCDPMessageTime(new Date());
|
|
300
|
+
// Handle responses to our requests (e.g., Runtime.evaluate)
|
|
301
|
+
if (typeof message.id === "number") {
|
|
302
|
+
const pending = pendingExecutions.get(message.id);
|
|
303
|
+
if (pending) {
|
|
304
|
+
clearTimeout(pending.timeoutId);
|
|
305
|
+
pendingExecutions.delete(message.id);
|
|
306
|
+
// Check for CDP-level error (protocol error, not JS exception)
|
|
307
|
+
if (message.error) {
|
|
308
|
+
const error = message.error;
|
|
309
|
+
// Build comprehensive error message including code and data if available
|
|
310
|
+
const parts = [];
|
|
311
|
+
if (error.message)
|
|
312
|
+
parts.push(error.message);
|
|
313
|
+
if (error.code !== undefined)
|
|
314
|
+
parts.push(`(code: ${error.code})`);
|
|
315
|
+
if (error.data)
|
|
316
|
+
parts.push(`- ${error.data}`);
|
|
317
|
+
const errorMessage = parts.length > 0 ? parts.join(' ') : 'Unknown CDP protocol error';
|
|
318
|
+
pending.resolve({ success: false, error: errorMessage });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Check for JavaScript exception in result
|
|
322
|
+
const result = message.result;
|
|
323
|
+
if (result?.exceptionDetails) {
|
|
324
|
+
const errorMessage = extractExceptionMessage(result.exceptionDetails);
|
|
325
|
+
pending.resolve({ success: false, error: errorMessage });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// Success - format the result
|
|
329
|
+
if (result?.result) {
|
|
330
|
+
pending.resolve({ success: true, result: formatRemoteObject(result.result) });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
pending.resolve({ success: true, result: "undefined" });
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const method = message.method;
|
|
338
|
+
// Handle Runtime.consoleAPICalled
|
|
339
|
+
if (method === "Runtime.consoleAPICalled") {
|
|
340
|
+
const params = message.params;
|
|
341
|
+
const type = params.type || "log";
|
|
342
|
+
const level = mapConsoleType(type);
|
|
343
|
+
const args = params.args || [];
|
|
344
|
+
// Check if any args need async object resolution
|
|
345
|
+
const hasObjectArgs = ws && args.some((a) => a.type === "object" && a.objectId && !a.preview?.properties);
|
|
346
|
+
if (hasObjectArgs) {
|
|
347
|
+
// Resolve object args asynchronously via CDP
|
|
348
|
+
resolveConsoleArgs(ws, args).then((messageText) => {
|
|
349
|
+
if (messageText.trim()) {
|
|
350
|
+
logBuffer.add({
|
|
351
|
+
timestamp: new Date(),
|
|
352
|
+
level,
|
|
353
|
+
message: messageText,
|
|
354
|
+
args: args.map((a) => a.value)
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}).catch(() => {
|
|
358
|
+
// Fallback to sync formatting on error
|
|
359
|
+
const messageText = args.map(formatConsoleArg).join(" ");
|
|
360
|
+
if (messageText.trim()) {
|
|
361
|
+
logBuffer.add({
|
|
362
|
+
timestamp: new Date(),
|
|
363
|
+
level,
|
|
364
|
+
message: messageText,
|
|
365
|
+
args: args.map((a) => a.value)
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const messageText = args.map(formatConsoleArg).join(" ");
|
|
372
|
+
if (messageText.trim()) {
|
|
373
|
+
logBuffer.add({
|
|
374
|
+
timestamp: new Date(),
|
|
375
|
+
level,
|
|
376
|
+
message: messageText,
|
|
377
|
+
args: args.map((a) => a.value)
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Handle Log.entryAdded
|
|
383
|
+
if (method === "Log.entryAdded") {
|
|
384
|
+
const params = message.params;
|
|
385
|
+
if (params.entry) {
|
|
386
|
+
const level = mapConsoleType(params.entry.level || "log");
|
|
387
|
+
logBuffer.add({
|
|
388
|
+
timestamp: new Date(),
|
|
389
|
+
level,
|
|
390
|
+
message: params.entry.text || ""
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Handle Network.requestWillBeSent
|
|
395
|
+
if (method === "Network.requestWillBeSent") {
|
|
396
|
+
const params = message.params;
|
|
397
|
+
const request = {
|
|
398
|
+
requestId: params.requestId,
|
|
399
|
+
timestamp: new Date(),
|
|
400
|
+
method: params.request.method,
|
|
401
|
+
url: params.request.url,
|
|
402
|
+
headers: params.request.headers || {},
|
|
403
|
+
postData: params.request.postData,
|
|
404
|
+
timing: {
|
|
405
|
+
requestTime: params.timestamp
|
|
406
|
+
},
|
|
407
|
+
completed: false
|
|
408
|
+
};
|
|
409
|
+
networkBuffer.set(params.requestId, request);
|
|
410
|
+
}
|
|
411
|
+
// Handle Network.responseReceived
|
|
412
|
+
if (method === "Network.responseReceived") {
|
|
413
|
+
const params = message.params;
|
|
414
|
+
const existing = networkBuffer.get(params.requestId);
|
|
415
|
+
if (existing) {
|
|
416
|
+
existing.status = params.response.status;
|
|
417
|
+
existing.statusText = params.response.statusText;
|
|
418
|
+
existing.responseHeaders = params.response.headers || {};
|
|
419
|
+
existing.mimeType = params.response.mimeType;
|
|
420
|
+
if (params.timestamp && existing.timing?.requestTime) {
|
|
421
|
+
existing.timing.responseTime = params.timestamp;
|
|
422
|
+
}
|
|
423
|
+
networkBuffer.set(params.requestId, existing);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Handle Network.loadingFinished
|
|
427
|
+
if (method === "Network.loadingFinished") {
|
|
428
|
+
const params = message.params;
|
|
429
|
+
const existing = networkBuffer.get(params.requestId);
|
|
430
|
+
if (existing) {
|
|
431
|
+
existing.completed = true;
|
|
432
|
+
existing.contentLength = params.encodedDataLength;
|
|
433
|
+
if (params.timestamp && existing.timing?.requestTime) {
|
|
434
|
+
existing.timing.duration = Math.round((params.timestamp - existing.timing.requestTime) * 1000);
|
|
435
|
+
}
|
|
436
|
+
networkBuffer.set(params.requestId, existing);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Handle Network.loadingFailed
|
|
440
|
+
if (method === "Network.loadingFailed") {
|
|
441
|
+
const params = message.params;
|
|
442
|
+
const existing = networkBuffer.get(params.requestId);
|
|
443
|
+
if (existing) {
|
|
444
|
+
existing.completed = true;
|
|
445
|
+
existing.error = params.canceled ? "Canceled" : (params.errorText || "Request failed");
|
|
446
|
+
networkBuffer.set(params.requestId, existing);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Handle Runtime context lifecycle events for health tracking
|
|
450
|
+
const appKey = findAppKeyForDevice(_device);
|
|
451
|
+
if (appKey) {
|
|
452
|
+
// Handle Runtime.executionContextCreated
|
|
453
|
+
if (method === "Runtime.executionContextCreated") {
|
|
454
|
+
const params = message.params;
|
|
455
|
+
markContextHealthy(appKey, params.context.id);
|
|
456
|
+
console.error(`[rn-ai-debugger] Context created: ${params.context.id}`);
|
|
457
|
+
}
|
|
458
|
+
// Handle Runtime.executionContextDestroyed
|
|
459
|
+
if (method === "Runtime.executionContextDestroyed") {
|
|
460
|
+
markContextStale(appKey);
|
|
461
|
+
console.error(`[rn-ai-debugger] Context destroyed`);
|
|
462
|
+
}
|
|
463
|
+
// Handle Runtime.executionContextsCleared
|
|
464
|
+
if (method === "Runtime.executionContextsCleared") {
|
|
465
|
+
markContextStale(appKey);
|
|
466
|
+
console.error(`[rn-ai-debugger] All contexts cleared`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// Connect to a device via CDP WebSocket
|
|
471
|
+
export async function connectToDevice(device, port, options = {}) {
|
|
472
|
+
const { isReconnection = false, reconnectionConfig = DEFAULT_RECONNECTION_CONFIG } = options;
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
const appKey = `${port}-${device.id}`;
|
|
475
|
+
// Check if already connected with a valid WebSocket
|
|
476
|
+
const existingApp = connectedApps.get(appKey);
|
|
477
|
+
if (existingApp) {
|
|
478
|
+
if (existingApp.ws.readyState === WebSocket.OPEN) {
|
|
479
|
+
resolve(`Already connected to ${device.title}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// WebSocket exists but not OPEN - clean up stale entry
|
|
483
|
+
console.error(`[rn-ai-debugger] Cleaning up stale connection for ${device.title} (state: ${getWebSocketStateName(existingApp.ws.readyState)})`);
|
|
484
|
+
connectedApps.delete(appKey);
|
|
485
|
+
}
|
|
486
|
+
// Prevent concurrent connection attempts to the same device
|
|
487
|
+
if (connectionLocks.has(appKey)) {
|
|
488
|
+
resolve(`Connection already in progress for ${device.title}`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
connectionLocks.add(appKey);
|
|
492
|
+
// Cancel any pending reconnection timer for this appKey
|
|
493
|
+
cancelReconnectionTimer(appKey);
|
|
494
|
+
// Save connection metadata for potential reconnection
|
|
495
|
+
saveConnectionMetadata(appKey, {
|
|
496
|
+
port,
|
|
497
|
+
deviceInfo: device,
|
|
498
|
+
webSocketUrl: device.webSocketDebuggerUrl
|
|
499
|
+
});
|
|
500
|
+
try {
|
|
501
|
+
const ws = new WebSocket(device.webSocketDebuggerUrl);
|
|
502
|
+
ws.on("open", async () => {
|
|
503
|
+
// Release connection lock
|
|
504
|
+
connectionLocks.delete(appKey);
|
|
505
|
+
connectedApps.set(appKey, { ws, deviceInfo: device, port, platform: "android" });
|
|
506
|
+
// Initialize or update connection state
|
|
507
|
+
// Note: We do NOT reset reconnectionAttempts here - that happens
|
|
508
|
+
// only when connection has been stable for MIN_STABLE_CONNECTION_MS
|
|
509
|
+
if (isReconnection) {
|
|
510
|
+
closeConnectionGap(appKey);
|
|
511
|
+
updateConnectionState(appKey, {
|
|
512
|
+
status: "connected",
|
|
513
|
+
lastConnectedTime: new Date()
|
|
514
|
+
// reconnectionAttempts NOT reset here - see ws.on("close") for stable connection check
|
|
515
|
+
});
|
|
516
|
+
// Reset context health for reconnection
|
|
517
|
+
initContextHealth(appKey);
|
|
518
|
+
console.error(`[rn-ai-debugger] Reconnected to ${device.title}`);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
initConnectionState(appKey);
|
|
522
|
+
initContextHealth(appKey);
|
|
523
|
+
console.error(`[rn-ai-debugger] Connected to ${device.title}`);
|
|
524
|
+
}
|
|
525
|
+
// Enable Runtime domain to receive console messages
|
|
526
|
+
ws.send(JSON.stringify({
|
|
527
|
+
id: getNextMessageId(),
|
|
528
|
+
method: "Runtime.enable"
|
|
529
|
+
}));
|
|
530
|
+
// Also enable Log domain
|
|
531
|
+
ws.send(JSON.stringify({
|
|
532
|
+
id: getNextMessageId(),
|
|
533
|
+
method: "Log.enable"
|
|
534
|
+
}));
|
|
535
|
+
// Enable Network domain to track requests
|
|
536
|
+
ws.send(JSON.stringify({
|
|
537
|
+
id: getNextMessageId(),
|
|
538
|
+
method: "Network.enable"
|
|
539
|
+
}));
|
|
540
|
+
// Try to resolve iOS simulator UDID from device name
|
|
541
|
+
// This enables automatic device scoping for iOS tools
|
|
542
|
+
if (device.deviceName) {
|
|
543
|
+
const simulatorUdid = await findSimulatorByName(device.deviceName);
|
|
544
|
+
if (simulatorUdid) {
|
|
545
|
+
setActiveSimulatorUdid(simulatorUdid, appKey);
|
|
546
|
+
// Update platform to ios now that simulator is confirmed
|
|
547
|
+
const connectedApp = connectedApps.get(appKey);
|
|
548
|
+
if (connectedApp) {
|
|
549
|
+
connectedApp.platform = "ios";
|
|
550
|
+
}
|
|
551
|
+
console.error(`[rn-ai-debugger] Linked to iOS simulator: ${simulatorUdid}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
resolve(`Connected to ${device.title} (${device.deviceName})`);
|
|
555
|
+
});
|
|
556
|
+
ws.on("message", (data) => {
|
|
557
|
+
try {
|
|
558
|
+
const message = JSON.parse(data.toString());
|
|
559
|
+
handleCDPMessage(message, device, ws);
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// Ignore non-JSON messages
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
ws.on("close", () => {
|
|
566
|
+
// Release connection lock if still held
|
|
567
|
+
connectionLocks.delete(appKey);
|
|
568
|
+
connectedApps.delete(appKey);
|
|
569
|
+
// Clear active simulator UDID if this connection set it
|
|
570
|
+
clearActiveSimulatorIfSource(appKey);
|
|
571
|
+
// Check if connection was stable before resetting attempts
|
|
572
|
+
const state = getConnectionState(appKey);
|
|
573
|
+
let wasStable = false;
|
|
574
|
+
if (state?.lastConnectedTime) {
|
|
575
|
+
const connectionDuration = Date.now() - state.lastConnectedTime.getTime();
|
|
576
|
+
wasStable = connectionDuration >= MIN_STABLE_CONNECTION_MS;
|
|
577
|
+
if (wasStable) {
|
|
578
|
+
// Connection was stable - reset attempts for fresh start
|
|
579
|
+
updateConnectionState(appKey, { reconnectionAttempts: 0 });
|
|
580
|
+
console.error(`[rn-ai-debugger] Connection was stable for ${Math.round(connectionDuration / 1000)}s, resetting reconnection attempts`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// Record the gap and trigger reconnection
|
|
584
|
+
recordConnectionGap(appKey, "Connection closed");
|
|
585
|
+
updateConnectionState(appKey, {
|
|
586
|
+
status: "disconnected",
|
|
587
|
+
lastDisconnectTime: new Date()
|
|
588
|
+
});
|
|
589
|
+
console.error(`[rn-ai-debugger] Disconnected from ${device.title}`);
|
|
590
|
+
// Schedule auto-reconnection if enabled (skip if intentionally disconnected)
|
|
591
|
+
if (reconnectionConfig.enabled && !reconnectionSuppressed.has(appKey)) {
|
|
592
|
+
scheduleReconnection(appKey, reconnectionConfig);
|
|
593
|
+
}
|
|
594
|
+
else if (reconnectionSuppressed.has(appKey)) {
|
|
595
|
+
reconnectionSuppressed.delete(appKey);
|
|
596
|
+
console.error(`[rn-ai-debugger] Reconnection suppressed for ${device.title} (intentional disconnect)`);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
ws.on("error", (error) => {
|
|
600
|
+
// Release connection lock
|
|
601
|
+
connectionLocks.delete(appKey);
|
|
602
|
+
// Cancel any pending reconnection timer to prevent orphaned loops
|
|
603
|
+
cancelReconnectionTimer(appKey);
|
|
604
|
+
connectedApps.delete(appKey);
|
|
605
|
+
// Clear active simulator UDID if this connection set it
|
|
606
|
+
clearActiveSimulatorIfSource(appKey);
|
|
607
|
+
// Extract error message safely - some WebSocket errors may not have a message
|
|
608
|
+
const errorMsg = error?.message || error?.toString() || 'Unknown WebSocket error';
|
|
609
|
+
// Only reject if this is initial connection, not reconnection attempt
|
|
610
|
+
if (!isReconnection) {
|
|
611
|
+
reject(`Failed to connect to ${device.title}: ${errorMsg}`);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
console.error(`[rn-ai-debugger] Reconnection error: ${errorMsg}`);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
// Timeout after 5 seconds
|
|
618
|
+
setTimeout(() => {
|
|
619
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
620
|
+
// Release connection lock on timeout
|
|
621
|
+
connectionLocks.delete(appKey);
|
|
622
|
+
ws.terminate();
|
|
623
|
+
if (!isReconnection) {
|
|
624
|
+
reject(`Connection to ${device.title} timed out`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}, 5000);
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
// Release connection lock on exception
|
|
631
|
+
connectionLocks.delete(appKey);
|
|
632
|
+
if (!isReconnection) {
|
|
633
|
+
const errorMessage = error instanceof Error ? error.message : (error ? String(error) : "Unknown error");
|
|
634
|
+
reject(`Failed to create WebSocket connection: ${errorMessage}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Schedule a reconnection attempt with exponential backoff
|
|
641
|
+
*/
|
|
642
|
+
function scheduleReconnection(appKey, config = DEFAULT_RECONNECTION_CONFIG) {
|
|
643
|
+
const state = getConnectionState(appKey);
|
|
644
|
+
if (!state)
|
|
645
|
+
return;
|
|
646
|
+
const attempts = state.reconnectionAttempts;
|
|
647
|
+
if (attempts >= config.maxAttempts) {
|
|
648
|
+
console.error(`[rn-ai-debugger] Max reconnection attempts (${config.maxAttempts}) reached for ${appKey}`);
|
|
649
|
+
updateConnectionState(appKey, { status: "disconnected" });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const delay = calculateBackoffDelay(attempts, config);
|
|
653
|
+
console.error(`[rn-ai-debugger] Scheduling reconnection attempt ${attempts + 1}/${config.maxAttempts} in ${delay}ms`);
|
|
654
|
+
updateConnectionState(appKey, {
|
|
655
|
+
status: "reconnecting",
|
|
656
|
+
reconnectionAttempts: attempts + 1
|
|
657
|
+
});
|
|
658
|
+
const timer = setTimeout(() => {
|
|
659
|
+
attemptReconnection(appKey, config);
|
|
660
|
+
}, delay);
|
|
661
|
+
saveReconnectionTimer(appKey, timer);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Attempt to reconnect to a previously connected device
|
|
665
|
+
*/
|
|
666
|
+
async function attemptReconnection(appKey, config = DEFAULT_RECONNECTION_CONFIG) {
|
|
667
|
+
const metadata = getConnectionMetadata(appKey);
|
|
668
|
+
if (!metadata) {
|
|
669
|
+
console.error(`[rn-ai-debugger] No metadata for reconnection: ${appKey}`);
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
// Re-fetch devices to get fresh WebSocket URL (may have changed)
|
|
674
|
+
const devices = await fetchDevices(metadata.port);
|
|
675
|
+
// Try to find the same device first, otherwise select main device
|
|
676
|
+
const device = devices.find(d => d.id === metadata.deviceInfo.id)
|
|
677
|
+
|| selectMainDevice(devices);
|
|
678
|
+
if (!device) {
|
|
679
|
+
console.error(`[rn-ai-debugger] Device no longer available for ${appKey}`);
|
|
680
|
+
// Schedule next attempt
|
|
681
|
+
scheduleReconnection(appKey, config);
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
await connectToDevice(device, metadata.port, { isReconnection: true, reconnectionConfig: config });
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
console.error(`[rn-ai-debugger] Reconnection failed: ${error}`);
|
|
689
|
+
// Schedule next attempt
|
|
690
|
+
scheduleReconnection(appKey, config);
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Get list of connected apps
|
|
695
|
+
export function getConnectedApps() {
|
|
696
|
+
return Array.from(connectedApps.entries()).map(([key, app]) => ({
|
|
697
|
+
key,
|
|
698
|
+
app,
|
|
699
|
+
isConnected: app.ws.readyState === WebSocket.OPEN
|
|
700
|
+
}));
|
|
701
|
+
}
|
|
702
|
+
// Get first connected app with an OPEN WebSocket (or null if none)
|
|
703
|
+
export function getFirstConnectedApp() {
|
|
704
|
+
// Find first app with OPEN WebSocket, cleaning up stale entries
|
|
705
|
+
for (const [key, app] of connectedApps.entries()) {
|
|
706
|
+
if (app.ws.readyState === WebSocket.OPEN) {
|
|
707
|
+
return app;
|
|
708
|
+
}
|
|
709
|
+
// Clean up stale entry
|
|
710
|
+
console.error(`[rn-ai-debugger] Cleaning up stale connection in getFirstConnectedApp: ${key} (state: ${getWebSocketStateName(app.ws.readyState)})`);
|
|
711
|
+
connectedApps.delete(key);
|
|
712
|
+
}
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
// Check if any app is connected with an OPEN WebSocket
|
|
716
|
+
export function hasConnectedApp() {
|
|
717
|
+
for (const [, app] of connectedApps.entries()) {
|
|
718
|
+
if (app.ws.readyState === WebSocket.OPEN) {
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Run a quick health check to verify the page context is responsive
|
|
726
|
+
* Returns true if the context can execute code, false otherwise
|
|
727
|
+
*/
|
|
728
|
+
export async function runQuickHealthCheck(app) {
|
|
729
|
+
const HEALTH_CHECK_TIMEOUT = 2000;
|
|
730
|
+
const messageId = getNextMessageId();
|
|
731
|
+
return new Promise((resolve) => {
|
|
732
|
+
const timeoutId = setTimeout(() => {
|
|
733
|
+
pendingExecutions.delete(messageId);
|
|
734
|
+
resolve(false);
|
|
735
|
+
}, HEALTH_CHECK_TIMEOUT);
|
|
736
|
+
pendingExecutions.set(messageId, {
|
|
737
|
+
resolve: (result) => {
|
|
738
|
+
clearTimeout(timeoutId);
|
|
739
|
+
pendingExecutions.delete(messageId);
|
|
740
|
+
// Update context health tracking
|
|
741
|
+
const appKey = findAppKeyForDevice(app.deviceInfo);
|
|
742
|
+
if (appKey) {
|
|
743
|
+
updateContextHealth(appKey, {
|
|
744
|
+
lastHealthCheck: new Date(),
|
|
745
|
+
lastHealthCheckSuccess: result.success,
|
|
746
|
+
isStale: !result.success,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
resolve(result.success);
|
|
750
|
+
},
|
|
751
|
+
timeoutId,
|
|
752
|
+
});
|
|
753
|
+
try {
|
|
754
|
+
app.ws.send(JSON.stringify({
|
|
755
|
+
id: messageId,
|
|
756
|
+
method: "Runtime.evaluate",
|
|
757
|
+
params: { expression: "1+1", returnByValue: true },
|
|
758
|
+
}));
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
clearTimeout(timeoutId);
|
|
762
|
+
pendingExecutions.delete(messageId);
|
|
763
|
+
resolve(false);
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Find the first available Metro port
|
|
769
|
+
*/
|
|
770
|
+
async function findFirstMetroPort() {
|
|
771
|
+
const ports = await scanMetroPorts();
|
|
772
|
+
return ports.length > 0 ? ports[0] : null;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Ensure a healthy connection to a React Native app
|
|
776
|
+
* This will verify or establish a connection, optionally running a health check
|
|
777
|
+
*/
|
|
778
|
+
export async function ensureConnection(options = {}) {
|
|
779
|
+
const { port, healthCheck = true, forceRefresh = false } = options;
|
|
780
|
+
let app = getFirstConnectedApp();
|
|
781
|
+
let wasReconnected = false;
|
|
782
|
+
// Force refresh if requested - close existing connection
|
|
783
|
+
if (forceRefresh && app) {
|
|
784
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
785
|
+
cancelReconnectionTimer(appKey);
|
|
786
|
+
try {
|
|
787
|
+
app.ws.close();
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
// Ignore close errors
|
|
791
|
+
}
|
|
792
|
+
connectedApps.delete(appKey);
|
|
793
|
+
app = null;
|
|
794
|
+
}
|
|
795
|
+
// Attempt connection if not connected
|
|
796
|
+
if (!app) {
|
|
797
|
+
const targetPort = port ?? await findFirstMetroPort();
|
|
798
|
+
if (!targetPort) {
|
|
799
|
+
return {
|
|
800
|
+
connected: false,
|
|
801
|
+
wasReconnected: false,
|
|
802
|
+
healthCheckPassed: false,
|
|
803
|
+
connectionInfo: null,
|
|
804
|
+
error: "No Metro server found. Make sure Metro bundler is running.",
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
const devices = await fetchDevices(targetPort);
|
|
808
|
+
const mainDevice = selectMainDevice(devices);
|
|
809
|
+
if (!mainDevice) {
|
|
810
|
+
return {
|
|
811
|
+
connected: false,
|
|
812
|
+
wasReconnected: false,
|
|
813
|
+
healthCheckPassed: false,
|
|
814
|
+
connectionInfo: null,
|
|
815
|
+
error: `No debuggable devices found on port ${targetPort}. Make sure the app is running.`,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
await connectToDevice(mainDevice, targetPort);
|
|
820
|
+
app = getFirstConnectedApp();
|
|
821
|
+
wasReconnected = true;
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
// Ensure we always have a meaningful error message
|
|
825
|
+
let errorMessage;
|
|
826
|
+
if (error instanceof Error) {
|
|
827
|
+
errorMessage = error.message;
|
|
828
|
+
}
|
|
829
|
+
else if (error !== undefined && error !== null) {
|
|
830
|
+
errorMessage = String(error);
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
errorMessage = "WebSocket connection failed with no error details";
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
connected: false,
|
|
837
|
+
wasReconnected: false,
|
|
838
|
+
healthCheckPassed: false,
|
|
839
|
+
connectionInfo: null,
|
|
840
|
+
error: `Connection failed: ${errorMessage}`,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (!app) {
|
|
845
|
+
return {
|
|
846
|
+
connected: false,
|
|
847
|
+
wasReconnected: false,
|
|
848
|
+
healthCheckPassed: false,
|
|
849
|
+
connectionInfo: null,
|
|
850
|
+
error: "Connection succeeded but app is not available",
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
// Run health check if requested
|
|
854
|
+
let healthCheckPassed = true;
|
|
855
|
+
if (healthCheck) {
|
|
856
|
+
healthCheckPassed = await runQuickHealthCheck(app);
|
|
857
|
+
// If health check failed and we haven't just reconnected, try reconnecting
|
|
858
|
+
if (!healthCheckPassed && !wasReconnected) {
|
|
859
|
+
console.error(`[rn-ai-debugger] Health check failed, attempting reconnection...`);
|
|
860
|
+
// Close and reconnect
|
|
861
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
862
|
+
const targetPort = app.port;
|
|
863
|
+
cancelReconnectionTimer(appKey);
|
|
864
|
+
try {
|
|
865
|
+
app.ws.close();
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
// Ignore
|
|
869
|
+
}
|
|
870
|
+
connectedApps.delete(appKey);
|
|
871
|
+
// Re-fetch devices and reconnect
|
|
872
|
+
const devices = await fetchDevices(targetPort);
|
|
873
|
+
const mainDevice = selectMainDevice(devices);
|
|
874
|
+
if (mainDevice) {
|
|
875
|
+
try {
|
|
876
|
+
await connectToDevice(mainDevice, targetPort);
|
|
877
|
+
app = getFirstConnectedApp();
|
|
878
|
+
wasReconnected = true;
|
|
879
|
+
// Re-run health check after reconnection
|
|
880
|
+
if (app) {
|
|
881
|
+
healthCheckPassed = await runQuickHealthCheck(app);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
// Failed to reconnect
|
|
886
|
+
healthCheckPassed = false;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// Build connection info
|
|
892
|
+
const appKey = app ? `${app.port}-${app.deviceInfo.id}` : null;
|
|
893
|
+
const connectionState = appKey ? getConnectionState(appKey) : null;
|
|
894
|
+
const contextHealth = appKey ? getContextHealth(appKey) : null;
|
|
895
|
+
let uptime = "unknown";
|
|
896
|
+
if (connectionState?.lastConnectedTime) {
|
|
897
|
+
const uptimeMs = Date.now() - connectionState.lastConnectedTime.getTime();
|
|
898
|
+
uptime = formatDuration(uptimeMs);
|
|
899
|
+
}
|
|
900
|
+
return {
|
|
901
|
+
connected: app !== null && app.ws.readyState === WebSocket.OPEN,
|
|
902
|
+
wasReconnected,
|
|
903
|
+
healthCheckPassed,
|
|
904
|
+
connectionInfo: app ? {
|
|
905
|
+
deviceTitle: app.deviceInfo.title,
|
|
906
|
+
port: app.port,
|
|
907
|
+
uptime,
|
|
908
|
+
contextId: contextHealth?.contextId ?? null,
|
|
909
|
+
} : null,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
export function getPassiveConnectionStatus() {
|
|
913
|
+
if (!hasConnectedApp()) {
|
|
914
|
+
return { connected: false, needsPing: false, reason: "no_connection" };
|
|
915
|
+
}
|
|
916
|
+
const app = getFirstConnectedApp();
|
|
917
|
+
if (app) {
|
|
918
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
919
|
+
const health = getContextHealth(appKey);
|
|
920
|
+
if (health?.isStale) {
|
|
921
|
+
return { connected: false, needsPing: false, reason: "context_stale" };
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const lastMessage = getLastCDPMessageTime();
|
|
925
|
+
if (!lastMessage) {
|
|
926
|
+
return { connected: false, needsPing: false, reason: "no_activity" };
|
|
927
|
+
}
|
|
928
|
+
const elapsed = Date.now() - lastMessage.getTime();
|
|
929
|
+
if (elapsed > STALE_ACTIVITY_THRESHOLD_MS) {
|
|
930
|
+
return { connected: true, needsPing: true, reason: "activity_stale" };
|
|
931
|
+
}
|
|
932
|
+
return { connected: true, needsPing: false, reason: "ok" };
|
|
933
|
+
}
|
|
934
|
+
export async function checkAndEnsureConnection() {
|
|
935
|
+
const passive = getPassiveConnectionStatus();
|
|
936
|
+
if (passive.connected && !passive.needsPing) {
|
|
937
|
+
return { connected: true, wasReconnected: false, message: null };
|
|
938
|
+
}
|
|
939
|
+
if (passive.connected && passive.needsPing) {
|
|
940
|
+
const app = getFirstConnectedApp();
|
|
941
|
+
if (app) {
|
|
942
|
+
const healthy = await runQuickHealthCheck(app);
|
|
943
|
+
if (healthy) {
|
|
944
|
+
return { connected: true, wasReconnected: false, message: null };
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
const result = await ensureConnection({ forceRefresh: true, healthCheck: true });
|
|
949
|
+
if (result.connected && result.healthCheckPassed) {
|
|
950
|
+
await new Promise(resolve => setTimeout(resolve, RECONNECT_SETTLE_MS));
|
|
951
|
+
return {
|
|
952
|
+
connected: true,
|
|
953
|
+
wasReconnected: true,
|
|
954
|
+
message: "[CONNECTION] Was stale, re-established. Earlier data may be incomplete; new data will appear on next call.",
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
connected: false,
|
|
959
|
+
wasReconnected: false,
|
|
960
|
+
message: "[CONNECTION] No active connection. Could not reconnect. Ensure Metro and the app are running, then call scan_metro.",
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
//# sourceMappingURL=connection.js.map
|