react-native-ai-debugger 1.1.4 → 1.4.21
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 +12 -1
- package/README.md +198 -1063
- package/build/__tests__/helpers/tap-test-helpers.d.ts +36 -0
- package/build/__tests__/helpers/tap-test-helpers.d.ts.map +1 -0
- package/build/__tests__/helpers/tap-test-helpers.js +130 -0
- package/build/__tests__/helpers/tap-test-helpers.js.map +1 -0
- package/build/__tests__/integration/connection-health.test.js +12 -6
- package/build/__tests__/integration/connection-health.test.js.map +1 -1
- package/build/__tests__/integration/execute-in-app.test.js +2 -1
- package/build/__tests__/integration/execute-in-app.test.js.map +1 -1
- package/build/__tests__/integration/tap-strategies.test.d.ts +2 -0
- package/build/__tests__/integration/tap-strategies.test.d.ts.map +1 -0
- package/build/__tests__/integration/tap-strategies.test.js +329 -0
- package/build/__tests__/integration/tap-strategies.test.js.map +1 -0
- package/build/__tests__/integration/tools.test.js +9 -10
- package/build/__tests__/integration/tools.test.js.map +1 -1
- package/build/__tests__/unit/burst.test.d.ts +2 -0
- package/build/__tests__/unit/burst.test.d.ts.map +1 -0
- package/build/__tests__/unit/burst.test.js +50 -0
- package/build/__tests__/unit/burst.test.js.map +1 -0
- package/build/__tests__/unit/connection-health.test.js +26 -16
- package/build/__tests__/unit/connection-health.test.js.map +1 -1
- package/build/__tests__/unit/fingerprint.test.d.ts +2 -0
- package/build/__tests__/unit/fingerprint.test.d.ts.map +1 -0
- package/build/__tests__/unit/fingerprint.test.js +89 -0
- package/build/__tests__/unit/fingerprint.test.js.map +1 -0
- package/build/__tests__/unit/imageBuffer.test.d.ts +2 -0
- package/build/__tests__/unit/imageBuffer.test.d.ts.map +1 -0
- package/build/__tests__/unit/imageBuffer.test.js +128 -0
- package/build/__tests__/unit/imageBuffer.test.js.map +1 -0
- package/build/__tests__/unit/logs.test.js +9 -0
- package/build/__tests__/unit/logs.test.js.map +1 -1
- package/build/__tests__/unit/networkInterceptor.test.d.ts +2 -0
- package/build/__tests__/unit/networkInterceptor.test.d.ts.map +1 -0
- package/build/__tests__/unit/networkInterceptor.test.js +104 -0
- package/build/__tests__/unit/networkInterceptor.test.js.map +1 -0
- package/build/__tests__/unit/screenshot-diff.test.d.ts +2 -0
- package/build/__tests__/unit/screenshot-diff.test.d.ts.map +1 -0
- package/build/__tests__/unit/screenshot-diff.test.js +97 -0
- package/build/__tests__/unit/screenshot-diff.test.js.map +1 -0
- package/build/__tests__/unit/tap.test.js +433 -28
- package/build/__tests__/unit/tap.test.js.map +1 -1
- package/build/core/android.d.ts.map +1 -1
- package/build/core/android.js +64 -29
- package/build/core/android.js.map +1 -1
- package/build/core/appDetection.d.ts +8 -0
- package/build/core/appDetection.d.ts.map +1 -0
- package/build/core/appDetection.js +91 -0
- package/build/core/appDetection.js.map +1 -0
- package/build/core/bundle.d.ts.map +1 -1
- package/build/core/bundle.js +15 -23
- package/build/core/bundle.js.map +1 -1
- package/build/core/config.d.ts +8 -0
- package/build/core/config.d.ts.map +1 -0
- package/build/core/config.js +27 -0
- package/build/core/config.js.map +1 -0
- package/build/core/connection.d.ts +47 -3
- package/build/core/connection.d.ts.map +1 -1
- package/build/core/connection.js +519 -124
- package/build/core/connection.js.map +1 -1
- package/build/core/executor.d.ts +57 -14
- package/build/core/executor.d.ts.map +1 -1
- package/build/core/executor.js +936 -208
- package/build/core/executor.js.map +1 -1
- package/build/core/fingerprint.d.ts +4 -0
- package/build/core/fingerprint.d.ts.map +1 -0
- package/build/core/fingerprint.js +47 -0
- package/build/core/fingerprint.js.map +1 -0
- package/build/core/guides.d.ts.map +1 -1
- package/build/core/guides.js +48 -9
- package/build/core/guides.js.map +1 -1
- package/build/core/httpServer.js.map +1 -1
- package/build/core/imageBuffer.d.ts +37 -0
- package/build/core/imageBuffer.d.ts.map +1 -0
- package/build/core/imageBuffer.js +58 -0
- package/build/core/imageBuffer.js.map +1 -0
- package/build/core/index.d.ts +21 -17
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js +21 -19
- package/build/core/index.js.map +1 -1
- package/build/core/ios.d.ts +36 -7
- package/build/core/ios.d.ts.map +1 -1
- package/build/core/ios.js +553 -323
- package/build/core/ios.js.map +1 -1
- package/build/core/license.d.ts +38 -0
- package/build/core/license.d.ts.map +1 -0
- package/build/core/license.js +265 -0
- package/build/core/license.js.map +1 -0
- package/build/core/logbox.d.ts +51 -0
- package/build/core/logbox.d.ts.map +1 -0
- package/build/core/logbox.js +258 -0
- package/build/core/logbox.js.map +1 -0
- package/build/core/logs.d.ts +1 -0
- package/build/core/logs.d.ts.map +1 -1
- package/build/core/logs.js +5 -0
- package/build/core/logs.js.map +1 -1
- package/build/core/metro.d.ts +8 -0
- package/build/core/metro.d.ts.map +1 -1
- package/build/core/metro.js +28 -0
- package/build/core/metro.js.map +1 -1
- package/build/core/networkInterceptor.d.ts +30 -0
- package/build/core/networkInterceptor.d.ts.map +1 -0
- package/build/core/networkInterceptor.js +207 -0
- package/build/core/networkInterceptor.js.map +1 -0
- package/build/core/ocr.d.ts +8 -3
- package/build/core/ocr.d.ts.map +1 -1
- package/build/core/ocr.js +116 -15
- package/build/core/ocr.js.map +1 -1
- package/build/core/posthog.d.ts +5 -0
- package/build/core/posthog.d.ts.map +1 -0
- package/build/core/posthog.js +46 -0
- package/build/core/posthog.js.map +1 -0
- package/build/core/sdkBridge.d.ts +67 -0
- package/build/core/sdkBridge.d.ts.map +1 -0
- package/build/core/sdkBridge.js +148 -0
- package/build/core/sdkBridge.js.map +1 -0
- package/build/core/state.d.ts +26 -5
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +83 -10
- package/build/core/state.js.map +1 -1
- package/build/core/telemetry.d.ts +19 -1
- package/build/core/telemetry.d.ts.map +1 -1
- package/build/core/telemetry.js +165 -8
- package/build/core/telemetry.js.map +1 -1
- package/build/core/types.d.ts +11 -0
- package/build/core/types.d.ts.map +1 -1
- package/build/index.js +1304 -304
- package/build/index.js.map +1 -1
- package/build/pro/screenshot-diff.d.ts +10 -0
- package/build/pro/screenshot-diff.d.ts.map +1 -0
- package/build/pro/screenshot-diff.js +38 -0
- package/build/pro/screenshot-diff.js.map +1 -0
- package/build/pro/tap.d.ts +160 -0
- package/build/pro/tap.d.ts.map +1 -0
- package/build/pro/tap.js +1215 -0
- package/build/pro/tap.js.map +1 -0
- package/build/tools/accountTools.d.ts +44 -0
- package/build/tools/accountTools.d.ts.map +1 -0
- package/build/tools/accountTools.js +217 -0
- package/build/tools/accountTools.js.map +1 -0
- package/package.json +11 -8
- package/build/core/httpServer.js +0 -2459
- package/build/core/httpServerProcess.js +0 -153
- package/build/httpServerStandalone.js +0 -31
package/build/core/connection.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
-
import { connectedApps, pendingExecutions, getNextMessageId,
|
|
2
|
+
import { connectedApps, pendingExecutions, getNextMessageId, getLogBuffer, getNetworkBuffer, setActiveSimulatorUdid, clearActiveSimulatorIfSource, updateLastCDPMessageTime, getLastCDPMessageTime, clearLastCDPMessageTime } from "./state.js";
|
|
3
3
|
import { mapConsoleType } from "./logs.js";
|
|
4
|
+
import { injectNetworkInterceptor, sendNetworkEnable, isInterceptorEvent, applyInterceptedEvent } from "./networkInterceptor.js";
|
|
4
5
|
import { findSimulatorByName } from "./ios.js";
|
|
5
6
|
import { fetchDevices, selectMainDevice, scanMetroPorts } from "./metro.js";
|
|
7
|
+
import { scheduleAppDetection } from "./appDetection.js";
|
|
6
8
|
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
9
|
// Connection locks to prevent concurrent connection attempts to the same device
|
|
8
10
|
const connectionLocks = new Set();
|
|
11
|
+
// Track Network.enable message IDs to detect CDP network support
|
|
12
|
+
const pendingNetworkEnableIds = new Set();
|
|
9
13
|
// Suppress auto-reconnection for intentionally disconnected devices
|
|
10
14
|
const reconnectionSuppressed = new Set();
|
|
11
15
|
/**
|
|
@@ -17,6 +21,13 @@ export function suppressReconnection() {
|
|
|
17
21
|
reconnectionSuppressed.add(key);
|
|
18
22
|
}
|
|
19
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Suppress auto-reconnection for a specific connection key.
|
|
26
|
+
* Used by disconnect_metro with device targeting.
|
|
27
|
+
*/
|
|
28
|
+
export function suppressReconnectionForKey(appKey) {
|
|
29
|
+
reconnectionSuppressed.add(appKey);
|
|
30
|
+
}
|
|
20
31
|
/**
|
|
21
32
|
* Clear reconnection suppression (called when user explicitly reconnects via scan_metro).
|
|
22
33
|
*/
|
|
@@ -24,7 +35,76 @@ export function clearReconnectionSuppression() {
|
|
|
24
35
|
reconnectionSuppressed.clear();
|
|
25
36
|
}
|
|
26
37
|
const STALE_ACTIVITY_THRESHOLD_MS = 30_000;
|
|
38
|
+
const PING_INTERVAL_MS = 1_000; // WebSocket ping/pong keepalive interval
|
|
27
39
|
const RECONNECT_SETTLE_MS = 500;
|
|
40
|
+
/**
|
|
41
|
+
* Creates a WebSocket connection with Origin header fallback.
|
|
42
|
+
* RN 0.85+ Metro requires the Origin header, but Expo SDK 55 Bridgeless
|
|
43
|
+
* rejects connections that include one. The rejection can manifest as:
|
|
44
|
+
* - Connection refused before open (error/close before open event)
|
|
45
|
+
* - Immediate close after open (open fires, then close with code 1006)
|
|
46
|
+
* Tries with Origin first, falls back to without on quick failure.
|
|
47
|
+
*/
|
|
48
|
+
export function createWebSocketWithOriginFallback(url, timeoutMs = 5000) {
|
|
49
|
+
const STABILIZE_MS = 500; // Wait after open to detect immediate close
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const wsUrl = new URL(url);
|
|
52
|
+
const origin = `http://${wsUrl.hostname}:${wsUrl.port}`;
|
|
53
|
+
let settled = false;
|
|
54
|
+
const tryConnect = (withOrigin) => {
|
|
55
|
+
const options = withOrigin ? { headers: { Origin: origin } } : undefined;
|
|
56
|
+
const ws = new WebSocket(url, options);
|
|
57
|
+
let opened = false;
|
|
58
|
+
ws.on("open", () => {
|
|
59
|
+
opened = true;
|
|
60
|
+
if (withOrigin) {
|
|
61
|
+
// Wait briefly to detect immediate close (Expo SDK 55 pattern)
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
if (settled)
|
|
64
|
+
return;
|
|
65
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
66
|
+
settled = true;
|
|
67
|
+
console.error("[rn-ai-debugger] WebSocket connected with Origin header");
|
|
68
|
+
resolve(ws);
|
|
69
|
+
}
|
|
70
|
+
// If not OPEN, the close handler will trigger fallback
|
|
71
|
+
}, STABILIZE_MS);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
if (settled)
|
|
75
|
+
return;
|
|
76
|
+
settled = true;
|
|
77
|
+
console.error("[rn-ai-debugger] WebSocket connected without Origin header");
|
|
78
|
+
resolve(ws);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const handleFailure = (error) => {
|
|
82
|
+
if (settled)
|
|
83
|
+
return;
|
|
84
|
+
ws.removeAllListeners();
|
|
85
|
+
ws.terminate();
|
|
86
|
+
if (withOrigin) {
|
|
87
|
+
const reason = opened ? "immediately closed after open" : "rejected before open";
|
|
88
|
+
console.error(`[rn-ai-debugger] Origin header connection ${reason}, retrying without...`);
|
|
89
|
+
tryConnect(false);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
settled = true;
|
|
93
|
+
reject(error || new Error("WebSocket connection failed"));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
ws.on("error", (err) => handleFailure(err));
|
|
97
|
+
ws.on("close", () => handleFailure());
|
|
98
|
+
};
|
|
99
|
+
tryConnect(true);
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
if (!settled) {
|
|
102
|
+
settled = true;
|
|
103
|
+
reject(new Error("WebSocket connection timed out"));
|
|
104
|
+
}
|
|
105
|
+
}, timeoutMs);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
28
108
|
// Helper to find appKey from device info by searching connectedApps
|
|
29
109
|
function findAppKeyForDevice(device) {
|
|
30
110
|
for (const [key, app] of connectedApps.entries()) {
|
|
@@ -44,6 +124,47 @@ function getWebSocketStateName(state) {
|
|
|
44
124
|
default: return `UNKNOWN(${state})`;
|
|
45
125
|
}
|
|
46
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Purge stale connections for scanned ports.
|
|
129
|
+
* When Metro restarts with a different app, /json returns new device IDs.
|
|
130
|
+
* Old connections keyed by the previous device IDs remain in connectedApps.
|
|
131
|
+
* This function removes connections on scanned ports whose device IDs
|
|
132
|
+
* are no longer present in the fresh device list from Metro.
|
|
133
|
+
*/
|
|
134
|
+
export function purgeStaleConnectionsForPorts(freshDevicesByPort) {
|
|
135
|
+
const purged = [];
|
|
136
|
+
const scannedPorts = new Set(freshDevicesByPort.keys());
|
|
137
|
+
for (const [appKey, app] of connectedApps.entries()) {
|
|
138
|
+
// Only check connections on ports we just scanned
|
|
139
|
+
if (!scannedPorts.has(app.port))
|
|
140
|
+
continue;
|
|
141
|
+
const freshDevices = freshDevicesByPort.get(app.port) || [];
|
|
142
|
+
// Check both device ID and appId — device IDs are per-physical-device
|
|
143
|
+
// (stable across Metro restarts), so a different app on the same device
|
|
144
|
+
// would have the same ID but a different appId
|
|
145
|
+
const matchingDevice = freshDevices.find(d => d.id === app.deviceInfo.id);
|
|
146
|
+
const stillValid = matchingDevice && matchingDevice.appId === app.deviceInfo.appId;
|
|
147
|
+
if (!stillValid) {
|
|
148
|
+
const name = app.deviceInfo.deviceName || app.deviceInfo.title;
|
|
149
|
+
const reason = !matchingDevice
|
|
150
|
+
? "device no longer in Metro /json"
|
|
151
|
+
: `appId changed: ${app.deviceInfo.appId} → ${matchingDevice.appId}`;
|
|
152
|
+
console.error(`[rn-ai-debugger] Purging stale connection: ${name} (port ${app.port}, id ${app.deviceInfo.id}) — ${reason}`);
|
|
153
|
+
// Suppress reconnection so close handler doesn't try to reconnect
|
|
154
|
+
reconnectionSuppressed.add(appKey);
|
|
155
|
+
cancelReconnectionTimer(appKey);
|
|
156
|
+
clearLastCDPMessageTime(appKey);
|
|
157
|
+
clearActiveSimulatorIfSource(appKey);
|
|
158
|
+
try {
|
|
159
|
+
app.ws.close();
|
|
160
|
+
}
|
|
161
|
+
catch { /* ignore */ }
|
|
162
|
+
connectedApps.delete(appKey);
|
|
163
|
+
purged.push(name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return purged;
|
|
167
|
+
}
|
|
47
168
|
// Format CDP RemoteObject to readable string
|
|
48
169
|
export function formatRemoteObject(result) {
|
|
49
170
|
if (result.type === "undefined") {
|
|
@@ -159,6 +280,10 @@ function formatPreview(preview) {
|
|
|
159
280
|
}
|
|
160
281
|
// Format a single CDP console argument (sync — without object resolution)
|
|
161
282
|
function formatConsoleArg(arg) {
|
|
283
|
+
// Explicit undefined (matches browser: console.log(undefined) → "undefined")
|
|
284
|
+
if (arg.type === "undefined") {
|
|
285
|
+
return "undefined";
|
|
286
|
+
}
|
|
162
287
|
// Primitives
|
|
163
288
|
if (arg.type === "string" || arg.type === "number" || arg.type === "boolean") {
|
|
164
289
|
return String(arg.value);
|
|
@@ -175,7 +300,11 @@ function formatConsoleArg(arg) {
|
|
|
175
300
|
if (arg.description) {
|
|
176
301
|
return arg.description;
|
|
177
302
|
}
|
|
178
|
-
|
|
303
|
+
// Object without any resolved data
|
|
304
|
+
if (arg.type === "object") {
|
|
305
|
+
return "[Object]";
|
|
306
|
+
}
|
|
307
|
+
return `[${arg.type || "unknown"}]`;
|
|
179
308
|
}
|
|
180
309
|
// Fetch object properties via CDP Runtime.getProperties
|
|
181
310
|
function fetchObjectProperties(ws, objectId, depth = 2) {
|
|
@@ -294,9 +423,13 @@ async function resolveConsoleArgs(ws, args) {
|
|
|
294
423
|
return parts.join(" ");
|
|
295
424
|
}
|
|
296
425
|
// Handle CDP messages
|
|
297
|
-
export function handleCDPMessage(message,
|
|
298
|
-
// Track last CDP activity for connection liveness detection
|
|
299
|
-
|
|
426
|
+
export function handleCDPMessage(message, device, ws) {
|
|
427
|
+
// Track last CDP activity for connection liveness detection (per-device)
|
|
428
|
+
const cdpAppKey = findAppKeyForDevice(device);
|
|
429
|
+
if (cdpAppKey) {
|
|
430
|
+
updateLastCDPMessageTime(cdpAppKey, new Date());
|
|
431
|
+
}
|
|
432
|
+
const deviceName = device.deviceName || device.title || "unknown";
|
|
300
433
|
// Handle responses to our requests (e.g., Runtime.evaluate)
|
|
301
434
|
if (typeof message.id === "number") {
|
|
302
435
|
const pending = pendingExecutions.get(message.id);
|
|
@@ -332,6 +465,24 @@ export function handleCDPMessage(message, _device, ws) {
|
|
|
332
465
|
}
|
|
333
466
|
pending.resolve({ success: true, result: "undefined" });
|
|
334
467
|
}
|
|
468
|
+
// Track Network.enable responses to detect CDP network support
|
|
469
|
+
if (pendingNetworkEnableIds.has(message.id)) {
|
|
470
|
+
pendingNetworkEnableIds.delete(message.id);
|
|
471
|
+
const nAppKey = findAppKeyForDevice(device);
|
|
472
|
+
if (nAppKey) {
|
|
473
|
+
const app = connectedApps.get(nAppKey);
|
|
474
|
+
if (app) {
|
|
475
|
+
if (message.error) {
|
|
476
|
+
app.cdpNetworkSupported = false;
|
|
477
|
+
console.error(`[rn-ai-debugger] CDP Network domain not supported, using JS interceptor`);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
app.cdpNetworkSupported = true;
|
|
481
|
+
console.error(`[rn-ai-debugger] CDP Network domain supported`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
335
486
|
return;
|
|
336
487
|
}
|
|
337
488
|
const method = message.method;
|
|
@@ -341,36 +492,42 @@ export function handleCDPMessage(message, _device, ws) {
|
|
|
341
492
|
const type = params.type || "log";
|
|
342
493
|
const level = mapConsoleType(type);
|
|
343
494
|
const args = params.args || [];
|
|
495
|
+
// Check for network interceptor events before processing as logs
|
|
496
|
+
const interceptorJson = isInterceptorEvent(args);
|
|
497
|
+
if (interceptorJson !== null) {
|
|
498
|
+
const iAppKey = findAppKeyForDevice(device);
|
|
499
|
+
const iApp = iAppKey ? connectedApps.get(iAppKey) : null;
|
|
500
|
+
if (!iApp?.cdpNetworkSupported) {
|
|
501
|
+
applyInterceptedEvent(interceptorJson, getNetworkBuffer(deviceName));
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
344
505
|
// Check if any args need async object resolution
|
|
345
506
|
const hasObjectArgs = ws && args.some((a) => a.type === "object" && a.objectId && !a.preview?.properties);
|
|
346
507
|
if (hasObjectArgs) {
|
|
347
508
|
// Resolve object args asynchronously via CDP
|
|
348
509
|
resolveConsoleArgs(ws, args).then((messageText) => {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
});
|
|
356
|
-
}
|
|
510
|
+
getLogBuffer(deviceName).add({
|
|
511
|
+
timestamp: new Date(),
|
|
512
|
+
level,
|
|
513
|
+
message: messageText || "[console call with empty resolution]",
|
|
514
|
+
args: args.map((a) => a.value)
|
|
515
|
+
});
|
|
357
516
|
}).catch(() => {
|
|
358
517
|
// Fallback to sync formatting on error
|
|
359
518
|
const messageText = args.map(formatConsoleArg).join(" ");
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
});
|
|
367
|
-
}
|
|
519
|
+
getLogBuffer(deviceName).add({
|
|
520
|
+
timestamp: new Date(),
|
|
521
|
+
level,
|
|
522
|
+
message: messageText || "[console call with unresolvable args]",
|
|
523
|
+
args: args.map((a) => a.value)
|
|
524
|
+
});
|
|
368
525
|
});
|
|
369
526
|
}
|
|
370
527
|
else {
|
|
371
528
|
const messageText = args.map(formatConsoleArg).join(" ");
|
|
372
529
|
if (messageText.trim()) {
|
|
373
|
-
|
|
530
|
+
getLogBuffer(deviceName).add({
|
|
374
531
|
timestamp: new Date(),
|
|
375
532
|
level,
|
|
376
533
|
message: messageText,
|
|
@@ -384,7 +541,7 @@ export function handleCDPMessage(message, _device, ws) {
|
|
|
384
541
|
const params = message.params;
|
|
385
542
|
if (params.entry) {
|
|
386
543
|
const level = mapConsoleType(params.entry.level || "log");
|
|
387
|
-
|
|
544
|
+
getLogBuffer(deviceName).add({
|
|
388
545
|
timestamp: new Date(),
|
|
389
546
|
level,
|
|
390
547
|
message: params.entry.text || ""
|
|
@@ -406,12 +563,12 @@ export function handleCDPMessage(message, _device, ws) {
|
|
|
406
563
|
},
|
|
407
564
|
completed: false
|
|
408
565
|
};
|
|
409
|
-
|
|
566
|
+
getNetworkBuffer(deviceName).set(params.requestId, request);
|
|
410
567
|
}
|
|
411
568
|
// Handle Network.responseReceived
|
|
412
569
|
if (method === "Network.responseReceived") {
|
|
413
570
|
const params = message.params;
|
|
414
|
-
const existing =
|
|
571
|
+
const existing = getNetworkBuffer(deviceName).get(params.requestId);
|
|
415
572
|
if (existing) {
|
|
416
573
|
existing.status = params.response.status;
|
|
417
574
|
existing.statusText = params.response.statusText;
|
|
@@ -420,40 +577,47 @@ export function handleCDPMessage(message, _device, ws) {
|
|
|
420
577
|
if (params.timestamp && existing.timing?.requestTime) {
|
|
421
578
|
existing.timing.responseTime = params.timestamp;
|
|
422
579
|
}
|
|
423
|
-
|
|
580
|
+
getNetworkBuffer(deviceName).set(params.requestId, existing);
|
|
424
581
|
}
|
|
425
582
|
}
|
|
426
583
|
// Handle Network.loadingFinished
|
|
427
584
|
if (method === "Network.loadingFinished") {
|
|
428
585
|
const params = message.params;
|
|
429
|
-
const existing =
|
|
586
|
+
const existing = getNetworkBuffer(deviceName).get(params.requestId);
|
|
430
587
|
if (existing) {
|
|
431
588
|
existing.completed = true;
|
|
432
589
|
existing.contentLength = params.encodedDataLength;
|
|
433
590
|
if (params.timestamp && existing.timing?.requestTime) {
|
|
434
591
|
existing.timing.duration = Math.round((params.timestamp - existing.timing.requestTime) * 1000);
|
|
435
592
|
}
|
|
436
|
-
|
|
593
|
+
getNetworkBuffer(deviceName).set(params.requestId, existing);
|
|
437
594
|
}
|
|
438
595
|
}
|
|
439
596
|
// Handle Network.loadingFailed
|
|
440
597
|
if (method === "Network.loadingFailed") {
|
|
441
598
|
const params = message.params;
|
|
442
|
-
const existing =
|
|
599
|
+
const existing = getNetworkBuffer(deviceName).get(params.requestId);
|
|
443
600
|
if (existing) {
|
|
444
601
|
existing.completed = true;
|
|
445
602
|
existing.error = params.canceled ? "Canceled" : (params.errorText || "Request failed");
|
|
446
|
-
|
|
603
|
+
getNetworkBuffer(deviceName).set(params.requestId, existing);
|
|
447
604
|
}
|
|
448
605
|
}
|
|
449
606
|
// Handle Runtime context lifecycle events for health tracking
|
|
450
|
-
const appKey = findAppKeyForDevice(
|
|
607
|
+
const appKey = findAppKeyForDevice(device);
|
|
451
608
|
if (appKey) {
|
|
452
609
|
// Handle Runtime.executionContextCreated
|
|
453
610
|
if (method === "Runtime.executionContextCreated") {
|
|
454
611
|
const params = message.params;
|
|
455
612
|
markContextHealthy(appKey, params.context.id);
|
|
456
613
|
console.error(`[rn-ai-debugger] Context created: ${params.context.id}`);
|
|
614
|
+
// Re-inject network interceptor and re-check CDP Network support
|
|
615
|
+
const ctxApp = connectedApps.get(appKey);
|
|
616
|
+
if (ctxApp?.ws?.readyState === WebSocket.OPEN) {
|
|
617
|
+
injectNetworkInterceptor(ctxApp.ws);
|
|
618
|
+
const nEnableId = sendNetworkEnable(ctxApp.ws);
|
|
619
|
+
pendingNetworkEnableIds.add(nEnableId);
|
|
620
|
+
}
|
|
457
621
|
}
|
|
458
622
|
// Handle Runtime.executionContextDestroyed
|
|
459
623
|
if (method === "Runtime.executionContextDestroyed") {
|
|
@@ -470,18 +634,61 @@ export function handleCDPMessage(message, _device, ws) {
|
|
|
470
634
|
// Connect to a device via CDP WebSocket
|
|
471
635
|
export async function connectToDevice(device, port, options = {}) {
|
|
472
636
|
const { isReconnection = false, reconnectionConfig = DEFAULT_RECONNECTION_CONFIG } = options;
|
|
473
|
-
return new Promise((resolve, reject) => {
|
|
637
|
+
return new Promise(async (resolve, reject) => {
|
|
474
638
|
const appKey = `${port}-${device.id}`;
|
|
475
639
|
// Check if already connected with a valid WebSocket
|
|
476
640
|
const existingApp = connectedApps.get(appKey);
|
|
477
641
|
if (existingApp) {
|
|
478
642
|
if (existingApp.ws.readyState === WebSocket.OPEN) {
|
|
479
|
-
|
|
480
|
-
|
|
643
|
+
// Verify WebSocket is actually alive with a ping/pong check.
|
|
644
|
+
// readyState can show OPEN even when the remote end is gone
|
|
645
|
+
// (TCP hasn't timed out yet, especially after Metro restart).
|
|
646
|
+
const isAlive = await new Promise((pongResolve) => {
|
|
647
|
+
const timeout = setTimeout(() => pongResolve(false), 2000);
|
|
648
|
+
existingApp.ws.once("pong", () => {
|
|
649
|
+
clearTimeout(timeout);
|
|
650
|
+
pongResolve(true);
|
|
651
|
+
});
|
|
652
|
+
try {
|
|
653
|
+
existingApp.ws.ping();
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
clearTimeout(timeout);
|
|
657
|
+
pongResolve(false);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
if (isAlive) {
|
|
661
|
+
resolve(`Already connected to ${device.title}`);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// Ping failed — connection is dead, clean up
|
|
665
|
+
console.error(`[rn-ai-debugger] Existing connection to ${device.title} failed liveness check (no pong), reconnecting`);
|
|
666
|
+
reconnectionSuppressed.add(appKey);
|
|
667
|
+
try {
|
|
668
|
+
existingApp.ws.terminate();
|
|
669
|
+
}
|
|
670
|
+
catch { /* ignore */ }
|
|
671
|
+
connectedApps.delete(appKey);
|
|
672
|
+
clearLastCDPMessageTime(appKey);
|
|
673
|
+
clearActiveSimulatorIfSource(appKey);
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
// WebSocket exists but not OPEN - clean up stale entry
|
|
677
|
+
console.error(`[rn-ai-debugger] Cleaning up stale connection for ${device.title} (state: ${getWebSocketStateName(existingApp.ws.readyState)})`);
|
|
678
|
+
connectedApps.delete(appKey);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
// Skip if this device is already connected on a different port
|
|
682
|
+
// (Metro's /json advertises all devices it can see, even ones built by other Metro servers)
|
|
683
|
+
const deviceName = device.deviceName || device.title;
|
|
684
|
+
for (const [existingKey, existingApp] of connectedApps.entries()) {
|
|
685
|
+
if (existingKey !== appKey && existingApp.ws.readyState === WebSocket.OPEN) {
|
|
686
|
+
const existingName = existingApp.deviceInfo.deviceName || existingApp.deviceInfo.title;
|
|
687
|
+
if (existingName === deviceName) {
|
|
688
|
+
resolve(`Skipped ${deviceName} (already connected on port ${existingApp.port})`);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
481
691
|
}
|
|
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
692
|
}
|
|
486
693
|
// Prevent concurrent connection attempts to the same device
|
|
487
694
|
if (connectionLocks.has(appKey)) {
|
|
@@ -498,60 +705,79 @@ export async function connectToDevice(device, port, options = {}) {
|
|
|
498
705
|
webSocketUrl: device.webSocketDebuggerUrl
|
|
499
706
|
});
|
|
500
707
|
try {
|
|
501
|
-
const ws =
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
console.error(`[rn-ai-debugger] Linked to iOS simulator: ${simulatorUdid}`);
|
|
708
|
+
const ws = await createWebSocketWithOriginFallback(device.webSocketDebuggerUrl);
|
|
709
|
+
// Connection established — run setup
|
|
710
|
+
connectionLocks.delete(appKey);
|
|
711
|
+
connectedApps.set(appKey, { ws, deviceInfo: device, port, platform: "android" });
|
|
712
|
+
// Initialize or update connection state
|
|
713
|
+
// Note: We do NOT reset reconnectionAttempts here - that happens
|
|
714
|
+
// only when connection has been stable for MIN_STABLE_CONNECTION_MS
|
|
715
|
+
if (isReconnection) {
|
|
716
|
+
closeConnectionGap(appKey);
|
|
717
|
+
updateConnectionState(appKey, {
|
|
718
|
+
status: "connected",
|
|
719
|
+
lastConnectedTime: new Date()
|
|
720
|
+
// reconnectionAttempts NOT reset here - see ws.on("close") for stable connection check
|
|
721
|
+
});
|
|
722
|
+
// Reset context health for reconnection
|
|
723
|
+
initContextHealth(appKey);
|
|
724
|
+
console.error(`[rn-ai-debugger] Reconnected to ${device.title}`);
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
initConnectionState(appKey);
|
|
728
|
+
initContextHealth(appKey);
|
|
729
|
+
console.error(`[rn-ai-debugger] Connected to ${device.title}`);
|
|
730
|
+
}
|
|
731
|
+
// Enable Runtime domain to receive console messages
|
|
732
|
+
ws.send(JSON.stringify({
|
|
733
|
+
id: getNextMessageId(),
|
|
734
|
+
method: "Runtime.enable"
|
|
735
|
+
}));
|
|
736
|
+
// Also enable Log domain
|
|
737
|
+
ws.send(JSON.stringify({
|
|
738
|
+
id: getNextMessageId(),
|
|
739
|
+
method: "Log.enable"
|
|
740
|
+
}));
|
|
741
|
+
// Inject JS network interceptor (immediate capture, may fail if context not ready)
|
|
742
|
+
injectNetworkInterceptor(ws);
|
|
743
|
+
// Also try CDP Network.enable (takes priority if supported)
|
|
744
|
+
const networkEnableId = sendNetworkEnable(ws);
|
|
745
|
+
pendingNetworkEnableIds.add(networkEnableId);
|
|
746
|
+
// Try to resolve iOS simulator UDID from device name
|
|
747
|
+
// This enables automatic device scoping for iOS tools
|
|
748
|
+
if (device.deviceName) {
|
|
749
|
+
const simulatorUdid = await findSimulatorByName(device.deviceName);
|
|
750
|
+
if (simulatorUdid) {
|
|
751
|
+
setActiveSimulatorUdid(simulatorUdid, appKey);
|
|
752
|
+
// Update platform to ios now that simulator is confirmed
|
|
753
|
+
const connectedApp = connectedApps.get(appKey);
|
|
754
|
+
if (connectedApp) {
|
|
755
|
+
connectedApp.platform = "ios";
|
|
756
|
+
connectedApp.simulatorUdid = simulatorUdid;
|
|
552
757
|
}
|
|
758
|
+
console.error(`[rn-ai-debugger] Linked to iOS simulator: ${simulatorUdid}`);
|
|
553
759
|
}
|
|
554
|
-
|
|
760
|
+
}
|
|
761
|
+
// Fire-and-forget app detection (500ms delayed, non-blocking)
|
|
762
|
+
const appForDetection = connectedApps.get(appKey);
|
|
763
|
+
if (appForDetection) {
|
|
764
|
+
scheduleAppDetection(appForDetection);
|
|
765
|
+
}
|
|
766
|
+
// Start WebSocket ping/pong keepalive to detect dead connections
|
|
767
|
+
// (especially important for physical devices over Wi-Fi)
|
|
768
|
+
let pongReceived = true;
|
|
769
|
+
const pingInterval = setInterval(() => {
|
|
770
|
+
if (!pongReceived) {
|
|
771
|
+
console.error(`[rn-ai-debugger] No pong from ${device.title}, terminating connection`);
|
|
772
|
+
clearInterval(pingInterval);
|
|
773
|
+
ws.terminate();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
pongReceived = false;
|
|
777
|
+
ws.ping();
|
|
778
|
+
}, PING_INTERVAL_MS);
|
|
779
|
+
ws.on("pong", () => {
|
|
780
|
+
pongReceived = true;
|
|
555
781
|
});
|
|
556
782
|
ws.on("message", (data) => {
|
|
557
783
|
try {
|
|
@@ -563,11 +789,15 @@ export async function connectToDevice(device, port, options = {}) {
|
|
|
563
789
|
}
|
|
564
790
|
});
|
|
565
791
|
ws.on("close", () => {
|
|
792
|
+
clearInterval(pingInterval);
|
|
566
793
|
// Release connection lock if still held
|
|
567
794
|
connectionLocks.delete(appKey);
|
|
568
795
|
connectedApps.delete(appKey);
|
|
796
|
+
clearLastCDPMessageTime(appKey);
|
|
569
797
|
// Clear active simulator UDID if this connection set it
|
|
570
798
|
clearActiveSimulatorIfSource(appKey);
|
|
799
|
+
// Keep log and network buffers across reconnections
|
|
800
|
+
// They are only cleared on demand via clear_logs / clear_network tools
|
|
571
801
|
// Check if connection was stable before resetting attempts
|
|
572
802
|
const state = getConnectionState(appKey);
|
|
573
803
|
let wasStable = false;
|
|
@@ -597,41 +827,23 @@ export async function connectToDevice(device, port, options = {}) {
|
|
|
597
827
|
}
|
|
598
828
|
});
|
|
599
829
|
ws.on("error", (error) => {
|
|
600
|
-
|
|
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
|
-
}
|
|
830
|
+
console.error(`[rn-ai-debugger] WebSocket error for ${device.title}: ${error?.message || error}`);
|
|
616
831
|
});
|
|
617
|
-
|
|
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);
|
|
832
|
+
resolve(`Connected to ${device.title} (${device.deviceName})`);
|
|
628
833
|
}
|
|
629
834
|
catch (error) {
|
|
630
|
-
//
|
|
835
|
+
// Connection failed (both with and without Origin header)
|
|
631
836
|
connectionLocks.delete(appKey);
|
|
837
|
+
cancelReconnectionTimer(appKey);
|
|
838
|
+
connectedApps.delete(appKey);
|
|
839
|
+
clearLastCDPMessageTime(appKey);
|
|
840
|
+
clearActiveSimulatorIfSource(appKey);
|
|
841
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
632
842
|
if (!isReconnection) {
|
|
633
|
-
|
|
634
|
-
|
|
843
|
+
reject(`Failed to connect to ${device.title}: ${errorMsg}`);
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
console.error(`[rn-ai-debugger] Reconnection error: ${errorMsg}`);
|
|
635
847
|
}
|
|
636
848
|
}
|
|
637
849
|
});
|
|
@@ -669,6 +881,14 @@ async function attemptReconnection(appKey, config = DEFAULT_RECONNECTION_CONFIG)
|
|
|
669
881
|
console.error(`[rn-ai-debugger] No metadata for reconnection: ${appKey}`);
|
|
670
882
|
return false;
|
|
671
883
|
}
|
|
884
|
+
// Quick check: is Metro even running on this port?
|
|
885
|
+
const { scanMetroPorts } = await import("./metro.js");
|
|
886
|
+
const openPorts = await scanMetroPorts(metadata.port, metadata.port);
|
|
887
|
+
if (openPorts.length === 0) {
|
|
888
|
+
console.error(`[rn-ai-debugger] Metro not running on port ${metadata.port}, stopping reconnection for ${appKey}`);
|
|
889
|
+
updateConnectionState(appKey, { status: "disconnected" });
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
672
892
|
try {
|
|
673
893
|
// Re-fetch devices to get fresh WebSocket URL (may have changed)
|
|
674
894
|
const devices = await fetchDevices(metadata.port);
|
|
@@ -712,6 +932,31 @@ export function getFirstConnectedApp() {
|
|
|
712
932
|
}
|
|
713
933
|
return null;
|
|
714
934
|
}
|
|
935
|
+
export function getConnectedAppByDevice(device) {
|
|
936
|
+
if (!device) {
|
|
937
|
+
return getFirstConnectedApp();
|
|
938
|
+
}
|
|
939
|
+
const lowerDevice = device.toLowerCase();
|
|
940
|
+
const matches = [];
|
|
941
|
+
for (const [key, app] of connectedApps.entries()) {
|
|
942
|
+
if (app.ws.readyState !== WebSocket.OPEN) {
|
|
943
|
+
connectedApps.delete(key);
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "";
|
|
947
|
+
if (deviceName.toLowerCase().includes(lowerDevice)) {
|
|
948
|
+
matches.push(app);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (matches.length === 1) {
|
|
952
|
+
return matches[0];
|
|
953
|
+
}
|
|
954
|
+
if (matches.length > 1) {
|
|
955
|
+
const names = matches.map(a => a.deviceInfo.deviceName || a.deviceInfo.title).join(", ");
|
|
956
|
+
throw new Error(`Multiple devices match "${device}": ${names}. Be more specific.`);
|
|
957
|
+
}
|
|
958
|
+
throw new Error(`No connected device matches "${device}". Run get_apps to see available devices.`);
|
|
959
|
+
}
|
|
715
960
|
// Check if any app is connected with an OPEN WebSocket
|
|
716
961
|
export function hasConnectedApp() {
|
|
717
962
|
for (const [, app] of connectedApps.entries()) {
|
|
@@ -764,6 +1009,115 @@ export async function runQuickHealthCheck(app) {
|
|
|
764
1009
|
}
|
|
765
1010
|
});
|
|
766
1011
|
}
|
|
1012
|
+
const LOG_PIPELINE_MARKER_PREFIX = "__rn_devtools_health_";
|
|
1013
|
+
/**
|
|
1014
|
+
* Inject a sentinel console.log via CDP and wait for it to appear in the log buffer.
|
|
1015
|
+
* Returns true if the marker arrives within TIMEOUT_MS.
|
|
1016
|
+
*/
|
|
1017
|
+
async function sendAndWaitForMarker(app, buffer) {
|
|
1018
|
+
const marker = `${LOG_PIPELINE_MARKER_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1019
|
+
const TIMEOUT_MS = 3000;
|
|
1020
|
+
try {
|
|
1021
|
+
app.ws.send(JSON.stringify({
|
|
1022
|
+
id: getNextMessageId(),
|
|
1023
|
+
method: "Runtime.evaluate",
|
|
1024
|
+
params: { expression: `console.log("${marker}")`, returnByValue: true },
|
|
1025
|
+
}));
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
// Poll the buffer for the marker (check every 50ms, up to TIMEOUT_MS)
|
|
1031
|
+
const startTime = Date.now();
|
|
1032
|
+
while (Date.now() - startTime < TIMEOUT_MS) {
|
|
1033
|
+
if (buffer.getAll().some(entry => entry.message.includes(marker))) {
|
|
1034
|
+
buffer.removeByText(marker);
|
|
1035
|
+
return true;
|
|
1036
|
+
}
|
|
1037
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1038
|
+
}
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* End-to-end log pipeline verification with automatic recovery.
|
|
1043
|
+
*
|
|
1044
|
+
* 1. Injects a sentinel console.log via CDP and verifies it appears in the buffer.
|
|
1045
|
+
* 2. If the marker doesn't arrive, attempts recovery:
|
|
1046
|
+
* a. Re-sends Runtime.enable + Log.enable (fixes missing domain subscription)
|
|
1047
|
+
* b. Retries the marker once
|
|
1048
|
+
* 3. If still broken, force-reconnects via ensureConnection and retries.
|
|
1049
|
+
*
|
|
1050
|
+
* Returns { ok, recovered, message } — the caller decides what to show.
|
|
1051
|
+
*/
|
|
1052
|
+
export async function verifyLogPipeline(app) {
|
|
1053
|
+
const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
|
|
1054
|
+
const buffer = getLogBuffer(deviceName);
|
|
1055
|
+
// --- Attempt 1: quick check ---
|
|
1056
|
+
if (await sendAndWaitForMarker(app, buffer)) {
|
|
1057
|
+
return { ok: true, recovered: false, message: null };
|
|
1058
|
+
}
|
|
1059
|
+
// --- Attempt 2: re-enable Runtime/Log domains and retry ---
|
|
1060
|
+
try {
|
|
1061
|
+
app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Runtime.enable" }));
|
|
1062
|
+
app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Log.enable" }));
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
// WS send failed — fall through to reconnect
|
|
1066
|
+
}
|
|
1067
|
+
// Brief pause for domain activation
|
|
1068
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1069
|
+
if (await sendAndWaitForMarker(app, buffer)) {
|
|
1070
|
+
return { ok: true, recovered: true, message: "[CONNECTION] Log pipeline was stale — re-enabled CDP domains. Logs are flowing again." };
|
|
1071
|
+
}
|
|
1072
|
+
// --- Attempt 3: force reconnect and retry ---
|
|
1073
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
1074
|
+
const port = app.port;
|
|
1075
|
+
cancelReconnectionTimer(appKey);
|
|
1076
|
+
try {
|
|
1077
|
+
app.ws.close();
|
|
1078
|
+
}
|
|
1079
|
+
catch { /* ignore */ }
|
|
1080
|
+
connectedApps.delete(appKey);
|
|
1081
|
+
const result = await ensureConnection({ port, forceRefresh: false, healthCheck: true });
|
|
1082
|
+
if (!result.connected || !result.healthCheckPassed) {
|
|
1083
|
+
return {
|
|
1084
|
+
ok: false,
|
|
1085
|
+
recovered: false,
|
|
1086
|
+
message: "[CONNECTION] Log pipeline broken and reconnection failed. Ensure the app is running, then call scan_metro.",
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
// Wait for the new connection to settle
|
|
1090
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1091
|
+
// Get the fresh app reference after reconnection
|
|
1092
|
+
const freshApp = getFirstConnectedApp();
|
|
1093
|
+
if (!freshApp) {
|
|
1094
|
+
return {
|
|
1095
|
+
ok: false,
|
|
1096
|
+
recovered: false,
|
|
1097
|
+
message: "[CONNECTION] Reconnected but no app available. Call scan_metro.",
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
const freshDeviceName = freshApp.deviceInfo.deviceName || freshApp.deviceInfo.title || "unknown";
|
|
1101
|
+
const freshBuffer = getLogBuffer(freshDeviceName);
|
|
1102
|
+
if (await sendAndWaitForMarker(freshApp, freshBuffer)) {
|
|
1103
|
+
return {
|
|
1104
|
+
ok: true,
|
|
1105
|
+
recovered: true,
|
|
1106
|
+
message: "[CONNECTION] Log pipeline was broken — reconnected and verified. Logs are flowing again.",
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
return {
|
|
1110
|
+
ok: false,
|
|
1111
|
+
recovered: false,
|
|
1112
|
+
message: "[WARNING] Log pipeline verification failed after reconnection. Console events are not reaching the buffer. Try reload_app or scan_metro.",
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Check if a log message is a health check marker (for filtering from output).
|
|
1117
|
+
*/
|
|
1118
|
+
export function isHealthCheckMarker(message) {
|
|
1119
|
+
return message.includes(LOG_PIPELINE_MARKER_PREFIX);
|
|
1120
|
+
}
|
|
767
1121
|
/**
|
|
768
1122
|
* Find the first available Metro port
|
|
769
1123
|
*/
|
|
@@ -897,8 +1251,9 @@ export async function ensureConnection(options = {}) {
|
|
|
897
1251
|
const uptimeMs = Date.now() - connectionState.lastConnectedTime.getTime();
|
|
898
1252
|
uptime = formatDuration(uptimeMs);
|
|
899
1253
|
}
|
|
1254
|
+
const isConnected = app !== null && app.ws.readyState === WebSocket.OPEN;
|
|
900
1255
|
return {
|
|
901
|
-
connected:
|
|
1256
|
+
connected: isConnected,
|
|
902
1257
|
wasReconnected,
|
|
903
1258
|
healthCheckPassed,
|
|
904
1259
|
connectionInfo: app ? {
|
|
@@ -907,9 +1262,35 @@ export async function ensureConnection(options = {}) {
|
|
|
907
1262
|
uptime,
|
|
908
1263
|
contextId: contextHealth?.contextId ?? null,
|
|
909
1264
|
} : null,
|
|
1265
|
+
...(!isConnected && {
|
|
1266
|
+
error: app
|
|
1267
|
+
? `Connection lost: WebSocket is in ${["CONNECTING", "OPEN", "CLOSING", "CLOSED"][app.ws.readyState] || "unknown"} state (expected OPEN). Health check ${healthCheckPassed ? "passed" : "failed"}. Try ensure_connection with forceRefresh=true.`
|
|
1268
|
+
: "App became unavailable after reconnection attempt. Try running scan_metro then ensure_connection."
|
|
1269
|
+
}),
|
|
910
1270
|
};
|
|
911
1271
|
}
|
|
912
|
-
export function getPassiveConnectionStatus() {
|
|
1272
|
+
export function getPassiveConnectionStatus(targetAppKey) {
|
|
1273
|
+
if (targetAppKey) {
|
|
1274
|
+
// Check a specific device's connection
|
|
1275
|
+
const app = connectedApps.get(targetAppKey);
|
|
1276
|
+
if (!app || app.ws.readyState !== WebSocket.OPEN) {
|
|
1277
|
+
return { connected: false, needsPing: false, reason: "no_connection" };
|
|
1278
|
+
}
|
|
1279
|
+
const health = getContextHealth(targetAppKey);
|
|
1280
|
+
if (health?.isStale) {
|
|
1281
|
+
return { connected: false, needsPing: false, reason: "context_stale" };
|
|
1282
|
+
}
|
|
1283
|
+
const lastMessage = getLastCDPMessageTime(targetAppKey);
|
|
1284
|
+
if (!lastMessage) {
|
|
1285
|
+
return { connected: false, needsPing: false, reason: "no_activity" };
|
|
1286
|
+
}
|
|
1287
|
+
const elapsed = Date.now() - lastMessage.getTime();
|
|
1288
|
+
if (elapsed > STALE_ACTIVITY_THRESHOLD_MS) {
|
|
1289
|
+
return { connected: true, needsPing: true, reason: "activity_stale" };
|
|
1290
|
+
}
|
|
1291
|
+
return { connected: true, needsPing: false, reason: "ok" };
|
|
1292
|
+
}
|
|
1293
|
+
// Default: check first connected app (backwards compatible)
|
|
913
1294
|
if (!hasConnectedApp()) {
|
|
914
1295
|
return { connected: false, needsPing: false, reason: "no_connection" };
|
|
915
1296
|
}
|
|
@@ -931,13 +1312,27 @@ export function getPassiveConnectionStatus() {
|
|
|
931
1312
|
}
|
|
932
1313
|
return { connected: true, needsPing: false, reason: "ok" };
|
|
933
1314
|
}
|
|
934
|
-
export async function checkAndEnsureConnection() {
|
|
935
|
-
|
|
1315
|
+
export async function checkAndEnsureConnection(device) {
|
|
1316
|
+
// Resolve targeted device to appKey if specified
|
|
1317
|
+
let targetAppKey;
|
|
1318
|
+
let targetApp = null;
|
|
1319
|
+
if (device) {
|
|
1320
|
+
try {
|
|
1321
|
+
targetApp = getConnectedAppByDevice(device);
|
|
1322
|
+
if (targetApp) {
|
|
1323
|
+
targetAppKey = `${targetApp.port}-${targetApp.deviceInfo.id}`;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
catch {
|
|
1327
|
+
// Device not found — fall through to reconnection
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
const passive = getPassiveConnectionStatus(targetAppKey);
|
|
936
1331
|
if (passive.connected && !passive.needsPing) {
|
|
937
1332
|
return { connected: true, wasReconnected: false, message: null };
|
|
938
1333
|
}
|
|
939
1334
|
if (passive.connected && passive.needsPing) {
|
|
940
|
-
const app = getFirstConnectedApp();
|
|
1335
|
+
const app = targetApp ?? getFirstConnectedApp();
|
|
941
1336
|
if (app) {
|
|
942
1337
|
const healthy = await runQuickHealthCheck(app);
|
|
943
1338
|
if (healthy) {
|