react-native-ai-devtools 1.4.12 → 1.4.14
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/build/__tests__/integration/connection-health.test.js +12 -6
- package/build/__tests__/integration/connection-health.test.js.map +1 -1
- 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/logs.test.js +9 -0
- package/build/__tests__/unit/logs.test.js.map +1 -1
- package/build/core/bundle.d.ts.map +1 -1
- package/build/core/bundle.js +15 -24
- package/build/core/bundle.js.map +1 -1
- package/build/core/connection.d.ts +32 -2
- package/build/core/connection.d.ts.map +1 -1
- package/build/core/connection.js +328 -134
- package/build/core/connection.js.map +1 -1
- package/build/core/index.d.ts +4 -4
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js +3 -3
- package/build/core/index.js.map +1 -1
- 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 +7 -0
- package/build/core/metro.d.ts.map +1 -1
- package/build/core/metro.js +25 -0
- package/build/core/metro.js.map +1 -1
- package/build/core/state.d.ts +16 -2
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +34 -6
- package/build/core/state.js.map +1 -1
- package/build/core/telemetry.d.ts +4 -1
- package/build/core/telemetry.d.ts.map +1 -1
- package/build/core/telemetry.js +51 -2
- package/build/core/telemetry.js.map +1 -1
- package/build/index.js +110 -16
- package/build/index.js.map +1 -1
- package/package.json +1 -1
package/build/core/connection.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
-
import { connectedApps, pendingExecutions, getNextMessageId, getLogBuffer, getNetworkBuffer, setActiveSimulatorUdid, clearActiveSimulatorIfSource, updateLastCDPMessageTime, getLastCDPMessageTime } from "./state.js";
|
|
2
|
+
import { connectedApps, pendingExecutions, getNextMessageId, getLogBuffer, getNetworkBuffer, setActiveSimulatorUdid, clearActiveSimulatorIfSource, updateLastCDPMessageTime, getLastCDPMessageTime, clearLastCDPMessageTime } from "./state.js";
|
|
3
3
|
import { mapConsoleType } from "./logs.js";
|
|
4
4
|
import { injectNetworkInterceptor, sendNetworkEnable, isInterceptorEvent, applyInterceptedEvent } from "./networkInterceptor.js";
|
|
5
5
|
import { findSimulatorByName } from "./ios.js";
|
|
@@ -37,6 +37,74 @@ export function clearReconnectionSuppression() {
|
|
|
37
37
|
const STALE_ACTIVITY_THRESHOLD_MS = 30_000;
|
|
38
38
|
const PING_INTERVAL_MS = 1_000; // WebSocket ping/pong keepalive interval
|
|
39
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
|
+
}
|
|
40
108
|
// Helper to find appKey from device info by searching connectedApps
|
|
41
109
|
function findAppKeyForDevice(device) {
|
|
42
110
|
for (const [key, app] of connectedApps.entries()) {
|
|
@@ -171,6 +239,10 @@ function formatPreview(preview) {
|
|
|
171
239
|
}
|
|
172
240
|
// Format a single CDP console argument (sync — without object resolution)
|
|
173
241
|
function formatConsoleArg(arg) {
|
|
242
|
+
// Explicit undefined (matches browser: console.log(undefined) → "undefined")
|
|
243
|
+
if (arg.type === "undefined") {
|
|
244
|
+
return "undefined";
|
|
245
|
+
}
|
|
174
246
|
// Primitives
|
|
175
247
|
if (arg.type === "string" || arg.type === "number" || arg.type === "boolean") {
|
|
176
248
|
return String(arg.value);
|
|
@@ -187,7 +259,11 @@ function formatConsoleArg(arg) {
|
|
|
187
259
|
if (arg.description) {
|
|
188
260
|
return arg.description;
|
|
189
261
|
}
|
|
190
|
-
|
|
262
|
+
// Object without any resolved data
|
|
263
|
+
if (arg.type === "object") {
|
|
264
|
+
return "[Object]";
|
|
265
|
+
}
|
|
266
|
+
return `[${arg.type || "unknown"}]`;
|
|
191
267
|
}
|
|
192
268
|
// Fetch object properties via CDP Runtime.getProperties
|
|
193
269
|
function fetchObjectProperties(ws, objectId, depth = 2) {
|
|
@@ -307,8 +383,11 @@ async function resolveConsoleArgs(ws, args) {
|
|
|
307
383
|
}
|
|
308
384
|
// Handle CDP messages
|
|
309
385
|
export function handleCDPMessage(message, device, ws) {
|
|
310
|
-
// Track last CDP activity for connection liveness detection
|
|
311
|
-
|
|
386
|
+
// Track last CDP activity for connection liveness detection (per-device)
|
|
387
|
+
const cdpAppKey = findAppKeyForDevice(device);
|
|
388
|
+
if (cdpAppKey) {
|
|
389
|
+
updateLastCDPMessageTime(cdpAppKey, new Date());
|
|
390
|
+
}
|
|
312
391
|
const deviceName = device.deviceName || device.title || "unknown";
|
|
313
392
|
// Handle responses to our requests (e.g., Runtime.evaluate)
|
|
314
393
|
if (typeof message.id === "number") {
|
|
@@ -387,25 +466,21 @@ export function handleCDPMessage(message, device, ws) {
|
|
|
387
466
|
if (hasObjectArgs) {
|
|
388
467
|
// Resolve object args asynchronously via CDP
|
|
389
468
|
resolveConsoleArgs(ws, args).then((messageText) => {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
});
|
|
397
|
-
}
|
|
469
|
+
getLogBuffer(deviceName).add({
|
|
470
|
+
timestamp: new Date(),
|
|
471
|
+
level,
|
|
472
|
+
message: messageText || "[console call with empty resolution]",
|
|
473
|
+
args: args.map((a) => a.value)
|
|
474
|
+
});
|
|
398
475
|
}).catch(() => {
|
|
399
476
|
// Fallback to sync formatting on error
|
|
400
477
|
const messageText = args.map(formatConsoleArg).join(" ");
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
});
|
|
408
|
-
}
|
|
478
|
+
getLogBuffer(deviceName).add({
|
|
479
|
+
timestamp: new Date(),
|
|
480
|
+
level,
|
|
481
|
+
message: messageText || "[console call with unresolvable args]",
|
|
482
|
+
args: args.map((a) => a.value)
|
|
483
|
+
});
|
|
409
484
|
});
|
|
410
485
|
}
|
|
411
486
|
else {
|
|
@@ -518,7 +593,7 @@ export function handleCDPMessage(message, device, ws) {
|
|
|
518
593
|
// Connect to a device via CDP WebSocket
|
|
519
594
|
export async function connectToDevice(device, port, options = {}) {
|
|
520
595
|
const { isReconnection = false, reconnectionConfig = DEFAULT_RECONNECTION_CONFIG } = options;
|
|
521
|
-
return new Promise((resolve, reject) => {
|
|
596
|
+
return new Promise(async (resolve, reject) => {
|
|
522
597
|
const appKey = `${port}-${device.id}`;
|
|
523
598
|
// Check if already connected with a valid WebSocket
|
|
524
599
|
const existingApp = connectedApps.get(appKey);
|
|
@@ -558,88 +633,79 @@ export async function connectToDevice(device, port, options = {}) {
|
|
|
558
633
|
webSocketUrl: device.webSocketDebuggerUrl
|
|
559
634
|
});
|
|
560
635
|
try {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
if (
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
const connectedApp = connectedApps.get(appKey);
|
|
611
|
-
if (connectedApp) {
|
|
612
|
-
connectedApp.platform = "ios";
|
|
613
|
-
connectedApp.simulatorUdid = simulatorUdid;
|
|
614
|
-
}
|
|
615
|
-
console.error(`[rn-ai-debugger] Linked to iOS simulator: ${simulatorUdid}`);
|
|
636
|
+
const ws = await createWebSocketWithOriginFallback(device.webSocketDebuggerUrl);
|
|
637
|
+
// Connection established — run setup
|
|
638
|
+
connectionLocks.delete(appKey);
|
|
639
|
+
connectedApps.set(appKey, { ws, deviceInfo: device, port, platform: "android" });
|
|
640
|
+
// Initialize or update connection state
|
|
641
|
+
// Note: We do NOT reset reconnectionAttempts here - that happens
|
|
642
|
+
// only when connection has been stable for MIN_STABLE_CONNECTION_MS
|
|
643
|
+
if (isReconnection) {
|
|
644
|
+
closeConnectionGap(appKey);
|
|
645
|
+
updateConnectionState(appKey, {
|
|
646
|
+
status: "connected",
|
|
647
|
+
lastConnectedTime: new Date()
|
|
648
|
+
// reconnectionAttempts NOT reset here - see ws.on("close") for stable connection check
|
|
649
|
+
});
|
|
650
|
+
// Reset context health for reconnection
|
|
651
|
+
initContextHealth(appKey);
|
|
652
|
+
console.error(`[rn-ai-debugger] Reconnected to ${device.title}`);
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
initConnectionState(appKey);
|
|
656
|
+
initContextHealth(appKey);
|
|
657
|
+
console.error(`[rn-ai-debugger] Connected to ${device.title}`);
|
|
658
|
+
}
|
|
659
|
+
// Enable Runtime domain to receive console messages
|
|
660
|
+
ws.send(JSON.stringify({
|
|
661
|
+
id: getNextMessageId(),
|
|
662
|
+
method: "Runtime.enable"
|
|
663
|
+
}));
|
|
664
|
+
// Also enable Log domain
|
|
665
|
+
ws.send(JSON.stringify({
|
|
666
|
+
id: getNextMessageId(),
|
|
667
|
+
method: "Log.enable"
|
|
668
|
+
}));
|
|
669
|
+
// Inject JS network interceptor (immediate capture, may fail if context not ready)
|
|
670
|
+
injectNetworkInterceptor(ws);
|
|
671
|
+
// Also try CDP Network.enable (takes priority if supported)
|
|
672
|
+
const networkEnableId = sendNetworkEnable(ws);
|
|
673
|
+
pendingNetworkEnableIds.add(networkEnableId);
|
|
674
|
+
// Try to resolve iOS simulator UDID from device name
|
|
675
|
+
// This enables automatic device scoping for iOS tools
|
|
676
|
+
if (device.deviceName) {
|
|
677
|
+
const simulatorUdid = await findSimulatorByName(device.deviceName);
|
|
678
|
+
if (simulatorUdid) {
|
|
679
|
+
setActiveSimulatorUdid(simulatorUdid, appKey);
|
|
680
|
+
// Update platform to ios now that simulator is confirmed
|
|
681
|
+
const connectedApp = connectedApps.get(appKey);
|
|
682
|
+
if (connectedApp) {
|
|
683
|
+
connectedApp.platform = "ios";
|
|
684
|
+
connectedApp.simulatorUdid = simulatorUdid;
|
|
616
685
|
}
|
|
686
|
+
console.error(`[rn-ai-debugger] Linked to iOS simulator: ${simulatorUdid}`);
|
|
617
687
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
ws.terminate();
|
|
631
|
-
return;
|
|
632
|
-
}
|
|
633
|
-
pongReceived = false;
|
|
634
|
-
ws.ping();
|
|
635
|
-
}, PING_INTERVAL_MS);
|
|
636
|
-
ws.on("pong", () => {
|
|
637
|
-
pongReceived = true;
|
|
638
|
-
});
|
|
639
|
-
ws.on("close", () => {
|
|
688
|
+
}
|
|
689
|
+
// Fire-and-forget app detection (500ms delayed, non-blocking)
|
|
690
|
+
const appForDetection = connectedApps.get(appKey);
|
|
691
|
+
if (appForDetection) {
|
|
692
|
+
scheduleAppDetection(appForDetection);
|
|
693
|
+
}
|
|
694
|
+
// Start WebSocket ping/pong keepalive to detect dead connections
|
|
695
|
+
// (especially important for physical devices over Wi-Fi)
|
|
696
|
+
let pongReceived = true;
|
|
697
|
+
const pingInterval = setInterval(() => {
|
|
698
|
+
if (!pongReceived) {
|
|
699
|
+
console.error(`[rn-ai-debugger] No pong from ${device.title}, terminating connection`);
|
|
640
700
|
clearInterval(pingInterval);
|
|
641
|
-
|
|
642
|
-
|
|
701
|
+
ws.terminate();
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
pongReceived = false;
|
|
705
|
+
ws.ping();
|
|
706
|
+
}, PING_INTERVAL_MS);
|
|
707
|
+
ws.on("pong", () => {
|
|
708
|
+
pongReceived = true;
|
|
643
709
|
});
|
|
644
710
|
ws.on("message", (data) => {
|
|
645
711
|
try {
|
|
@@ -651,9 +717,11 @@ export async function connectToDevice(device, port, options = {}) {
|
|
|
651
717
|
}
|
|
652
718
|
});
|
|
653
719
|
ws.on("close", () => {
|
|
720
|
+
clearInterval(pingInterval);
|
|
654
721
|
// Release connection lock if still held
|
|
655
722
|
connectionLocks.delete(appKey);
|
|
656
723
|
connectedApps.delete(appKey);
|
|
724
|
+
clearLastCDPMessageTime(appKey);
|
|
657
725
|
// Clear active simulator UDID if this connection set it
|
|
658
726
|
clearActiveSimulatorIfSource(appKey);
|
|
659
727
|
// Keep log and network buffers across reconnections
|
|
@@ -687,41 +755,23 @@ export async function connectToDevice(device, port, options = {}) {
|
|
|
687
755
|
}
|
|
688
756
|
});
|
|
689
757
|
ws.on("error", (error) => {
|
|
690
|
-
|
|
691
|
-
connectionLocks.delete(appKey);
|
|
692
|
-
// Cancel any pending reconnection timer to prevent orphaned loops
|
|
693
|
-
cancelReconnectionTimer(appKey);
|
|
694
|
-
connectedApps.delete(appKey);
|
|
695
|
-
// Clear active simulator UDID if this connection set it
|
|
696
|
-
clearActiveSimulatorIfSource(appKey);
|
|
697
|
-
// Extract error message safely - some WebSocket errors may not have a message
|
|
698
|
-
const errorMsg = error?.message || error?.toString() || 'Unknown WebSocket error';
|
|
699
|
-
// Only reject if this is initial connection, not reconnection attempt
|
|
700
|
-
if (!isReconnection) {
|
|
701
|
-
reject(`Failed to connect to ${device.title}: ${errorMsg}`);
|
|
702
|
-
}
|
|
703
|
-
else {
|
|
704
|
-
console.error(`[rn-ai-debugger] Reconnection error: ${errorMsg}`);
|
|
705
|
-
}
|
|
758
|
+
console.error(`[rn-ai-debugger] WebSocket error for ${device.title}: ${error?.message || error}`);
|
|
706
759
|
});
|
|
707
|
-
|
|
708
|
-
setTimeout(() => {
|
|
709
|
-
if (ws.readyState !== WebSocket.OPEN) {
|
|
710
|
-
// Release connection lock on timeout
|
|
711
|
-
connectionLocks.delete(appKey);
|
|
712
|
-
ws.terminate();
|
|
713
|
-
if (!isReconnection) {
|
|
714
|
-
reject(`Connection to ${device.title} timed out`);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}, 5000);
|
|
760
|
+
resolve(`Connected to ${device.title} (${device.deviceName})`);
|
|
718
761
|
}
|
|
719
762
|
catch (error) {
|
|
720
|
-
//
|
|
763
|
+
// Connection failed (both with and without Origin header)
|
|
721
764
|
connectionLocks.delete(appKey);
|
|
765
|
+
cancelReconnectionTimer(appKey);
|
|
766
|
+
connectedApps.delete(appKey);
|
|
767
|
+
clearLastCDPMessageTime(appKey);
|
|
768
|
+
clearActiveSimulatorIfSource(appKey);
|
|
769
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
722
770
|
if (!isReconnection) {
|
|
723
|
-
|
|
724
|
-
|
|
771
|
+
reject(`Failed to connect to ${device.title}: ${errorMsg}`);
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
console.error(`[rn-ai-debugger] Reconnection error: ${errorMsg}`);
|
|
725
775
|
}
|
|
726
776
|
}
|
|
727
777
|
});
|
|
@@ -887,6 +937,115 @@ export async function runQuickHealthCheck(app) {
|
|
|
887
937
|
}
|
|
888
938
|
});
|
|
889
939
|
}
|
|
940
|
+
const LOG_PIPELINE_MARKER_PREFIX = "__rn_devtools_health_";
|
|
941
|
+
/**
|
|
942
|
+
* Inject a sentinel console.log via CDP and wait for it to appear in the log buffer.
|
|
943
|
+
* Returns true if the marker arrives within TIMEOUT_MS.
|
|
944
|
+
*/
|
|
945
|
+
async function sendAndWaitForMarker(app, buffer) {
|
|
946
|
+
const marker = `${LOG_PIPELINE_MARKER_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
947
|
+
const TIMEOUT_MS = 3000;
|
|
948
|
+
try {
|
|
949
|
+
app.ws.send(JSON.stringify({
|
|
950
|
+
id: getNextMessageId(),
|
|
951
|
+
method: "Runtime.evaluate",
|
|
952
|
+
params: { expression: `console.log("${marker}")`, returnByValue: true },
|
|
953
|
+
}));
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
// Poll the buffer for the marker (check every 50ms, up to TIMEOUT_MS)
|
|
959
|
+
const startTime = Date.now();
|
|
960
|
+
while (Date.now() - startTime < TIMEOUT_MS) {
|
|
961
|
+
if (buffer.getAll().some(entry => entry.message.includes(marker))) {
|
|
962
|
+
buffer.removeByText(marker);
|
|
963
|
+
return true;
|
|
964
|
+
}
|
|
965
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
966
|
+
}
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* End-to-end log pipeline verification with automatic recovery.
|
|
971
|
+
*
|
|
972
|
+
* 1. Injects a sentinel console.log via CDP and verifies it appears in the buffer.
|
|
973
|
+
* 2. If the marker doesn't arrive, attempts recovery:
|
|
974
|
+
* a. Re-sends Runtime.enable + Log.enable (fixes missing domain subscription)
|
|
975
|
+
* b. Retries the marker once
|
|
976
|
+
* 3. If still broken, force-reconnects via ensureConnection and retries.
|
|
977
|
+
*
|
|
978
|
+
* Returns { ok, recovered, message } — the caller decides what to show.
|
|
979
|
+
*/
|
|
980
|
+
export async function verifyLogPipeline(app) {
|
|
981
|
+
const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
|
|
982
|
+
const buffer = getLogBuffer(deviceName);
|
|
983
|
+
// --- Attempt 1: quick check ---
|
|
984
|
+
if (await sendAndWaitForMarker(app, buffer)) {
|
|
985
|
+
return { ok: true, recovered: false, message: null };
|
|
986
|
+
}
|
|
987
|
+
// --- Attempt 2: re-enable Runtime/Log domains and retry ---
|
|
988
|
+
try {
|
|
989
|
+
app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Runtime.enable" }));
|
|
990
|
+
app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Log.enable" }));
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
// WS send failed — fall through to reconnect
|
|
994
|
+
}
|
|
995
|
+
// Brief pause for domain activation
|
|
996
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
997
|
+
if (await sendAndWaitForMarker(app, buffer)) {
|
|
998
|
+
return { ok: true, recovered: true, message: "[CONNECTION] Log pipeline was stale — re-enabled CDP domains. Logs are flowing again." };
|
|
999
|
+
}
|
|
1000
|
+
// --- Attempt 3: force reconnect and retry ---
|
|
1001
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
1002
|
+
const port = app.port;
|
|
1003
|
+
cancelReconnectionTimer(appKey);
|
|
1004
|
+
try {
|
|
1005
|
+
app.ws.close();
|
|
1006
|
+
}
|
|
1007
|
+
catch { /* ignore */ }
|
|
1008
|
+
connectedApps.delete(appKey);
|
|
1009
|
+
const result = await ensureConnection({ port, forceRefresh: false, healthCheck: true });
|
|
1010
|
+
if (!result.connected || !result.healthCheckPassed) {
|
|
1011
|
+
return {
|
|
1012
|
+
ok: false,
|
|
1013
|
+
recovered: false,
|
|
1014
|
+
message: "[CONNECTION] Log pipeline broken and reconnection failed. Ensure the app is running, then call scan_metro.",
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
// Wait for the new connection to settle
|
|
1018
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1019
|
+
// Get the fresh app reference after reconnection
|
|
1020
|
+
const freshApp = getFirstConnectedApp();
|
|
1021
|
+
if (!freshApp) {
|
|
1022
|
+
return {
|
|
1023
|
+
ok: false,
|
|
1024
|
+
recovered: false,
|
|
1025
|
+
message: "[CONNECTION] Reconnected but no app available. Call scan_metro.",
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
const freshDeviceName = freshApp.deviceInfo.deviceName || freshApp.deviceInfo.title || "unknown";
|
|
1029
|
+
const freshBuffer = getLogBuffer(freshDeviceName);
|
|
1030
|
+
if (await sendAndWaitForMarker(freshApp, freshBuffer)) {
|
|
1031
|
+
return {
|
|
1032
|
+
ok: true,
|
|
1033
|
+
recovered: true,
|
|
1034
|
+
message: "[CONNECTION] Log pipeline was broken — reconnected and verified. Logs are flowing again.",
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
ok: false,
|
|
1039
|
+
recovered: false,
|
|
1040
|
+
message: "[WARNING] Log pipeline verification failed after reconnection. Console events are not reaching the buffer. Try reload_app or scan_metro.",
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Check if a log message is a health check marker (for filtering from output).
|
|
1045
|
+
*/
|
|
1046
|
+
export function isHealthCheckMarker(message) {
|
|
1047
|
+
return message.includes(LOG_PIPELINE_MARKER_PREFIX);
|
|
1048
|
+
}
|
|
890
1049
|
/**
|
|
891
1050
|
* Find the first available Metro port
|
|
892
1051
|
*/
|
|
@@ -1038,7 +1197,28 @@ export async function ensureConnection(options = {}) {
|
|
|
1038
1197
|
}),
|
|
1039
1198
|
};
|
|
1040
1199
|
}
|
|
1041
|
-
export function getPassiveConnectionStatus() {
|
|
1200
|
+
export function getPassiveConnectionStatus(targetAppKey) {
|
|
1201
|
+
if (targetAppKey) {
|
|
1202
|
+
// Check a specific device's connection
|
|
1203
|
+
const app = connectedApps.get(targetAppKey);
|
|
1204
|
+
if (!app || app.ws.readyState !== WebSocket.OPEN) {
|
|
1205
|
+
return { connected: false, needsPing: false, reason: "no_connection" };
|
|
1206
|
+
}
|
|
1207
|
+
const health = getContextHealth(targetAppKey);
|
|
1208
|
+
if (health?.isStale) {
|
|
1209
|
+
return { connected: false, needsPing: false, reason: "context_stale" };
|
|
1210
|
+
}
|
|
1211
|
+
const lastMessage = getLastCDPMessageTime(targetAppKey);
|
|
1212
|
+
if (!lastMessage) {
|
|
1213
|
+
return { connected: false, needsPing: false, reason: "no_activity" };
|
|
1214
|
+
}
|
|
1215
|
+
const elapsed = Date.now() - lastMessage.getTime();
|
|
1216
|
+
if (elapsed > STALE_ACTIVITY_THRESHOLD_MS) {
|
|
1217
|
+
return { connected: true, needsPing: true, reason: "activity_stale" };
|
|
1218
|
+
}
|
|
1219
|
+
return { connected: true, needsPing: false, reason: "ok" };
|
|
1220
|
+
}
|
|
1221
|
+
// Default: check first connected app (backwards compatible)
|
|
1042
1222
|
if (!hasConnectedApp()) {
|
|
1043
1223
|
return { connected: false, needsPing: false, reason: "no_connection" };
|
|
1044
1224
|
}
|
|
@@ -1060,13 +1240,27 @@ export function getPassiveConnectionStatus() {
|
|
|
1060
1240
|
}
|
|
1061
1241
|
return { connected: true, needsPing: false, reason: "ok" };
|
|
1062
1242
|
}
|
|
1063
|
-
export async function checkAndEnsureConnection() {
|
|
1064
|
-
|
|
1243
|
+
export async function checkAndEnsureConnection(device) {
|
|
1244
|
+
// Resolve targeted device to appKey if specified
|
|
1245
|
+
let targetAppKey;
|
|
1246
|
+
let targetApp = null;
|
|
1247
|
+
if (device) {
|
|
1248
|
+
try {
|
|
1249
|
+
targetApp = getConnectedAppByDevice(device);
|
|
1250
|
+
if (targetApp) {
|
|
1251
|
+
targetAppKey = `${targetApp.port}-${targetApp.deviceInfo.id}`;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
catch {
|
|
1255
|
+
// Device not found — fall through to reconnection
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
const passive = getPassiveConnectionStatus(targetAppKey);
|
|
1065
1259
|
if (passive.connected && !passive.needsPing) {
|
|
1066
1260
|
return { connected: true, wasReconnected: false, message: null };
|
|
1067
1261
|
}
|
|
1068
1262
|
if (passive.connected && passive.needsPing) {
|
|
1069
|
-
const app = getFirstConnectedApp();
|
|
1263
|
+
const app = targetApp ?? getFirstConnectedApp();
|
|
1070
1264
|
if (app) {
|
|
1071
1265
|
const healthy = await runQuickHealthCheck(app);
|
|
1072
1266
|
if (healthy) {
|