diff --git a/pkg/bridge/bridge.go b/pkg/bridge/bridge.go index d7aa6ad..702aa8f 100644 --- a/pkg/bridge/bridge.go +++ b/pkg/bridge/bridge.go @@ -29,6 +29,11 @@ type Bridge struct { // latestCtx tracks the most recent Juggler execution context per session latestCtxMu sync.RWMutex latestCtx map[string]string // jugglerSessionID → latest executionContextId + // mainCtx tracks the main frame's execution context per session. + // Unlike latestCtx, this survives subframe destruction — only cleared on navigation. + // Used as fallback when latestCtx is empty (subframe was destroyed). + mainCtxMu sync.RWMutex + mainCtx map[string]string // jugglerSessionID → main frame executionContextId // isolatedWorlds tracks isolated world names per CDP session for re-emission after navigation isolatedWorldsMu sync.RWMutex isolatedWorlds map[string][]isolatedWorldInfo // cdpSessionID → list of isolated worlds @@ -89,6 +94,7 @@ func New(b backend.Backend, sessions *cdp.SessionManager, server *cdp.Server, is ctxCounter: 100, loaderMap: make(map[string]string), latestCtx: make(map[string]string), + mainCtx: make(map[string]string), isolatedWorlds: make(map[string][]isolatedWorldInfo), nodeObjects: make(map[int]string), lastQuery: make(map[string]string), diff --git a/pkg/bridge/events.go b/pkg/bridge/events.go index 37fe3a1..3aa68ad 100644 --- a/pkg/bridge/events.go +++ b/pkg/bridge/events.go @@ -367,7 +367,7 @@ func (b *Bridge) SetupEventSubscriptions() { }) // Runtime.executionContextsCleared → Runtime.executionContextsCleared - // Also clear the ctxMap since all old context IDs are now stale + // Also clear the ctxMap and latestCtx since all old context IDs are now stale b.backend.Subscribe("Runtime.executionContextsCleared", func(jugglerSessionID string, params json.RawMessage) { cdpSessionID := b.resolveCDPSession(jugglerSessionID) if cdpSessionID != "" { @@ -376,6 +376,16 @@ func (b *Bridge) SetupEventSubscriptions() { b.ctxMap = make(map[int]string) b.ctxMapMu.Unlock() + // Clear latestCtx for this session to avoid stale context routing + b.latestCtxMu.Lock() + delete(b.latestCtx, jugglerSessionID) + b.latestCtxMu.Unlock() + + // Clear mainCtx too — navigation creates entirely new contexts + b.mainCtxMu.Lock() + delete(b.mainCtx, jugglerSessionID) + b.mainCtxMu.Unlock() + b.emitEvent("Runtime.executionContextsCleared", map[string]interface{}{}, cdpSessionID) // Mark for isolated world re-emission @@ -427,6 +437,20 @@ func (b *Bridge) SetupEventSubscriptions() { b.latestCtx[jugglerSessionID] = ev.ExecutionContextID b.latestCtxMu.Unlock() + // Track the main frame context separately. This survives subframe destruction + // and is used as fallback when latestCtx is cleared. + // Only update for main frame contexts (matching session's FrameID). + b.mainCtxMu.Lock() + if info, ok := b.sessions.GetByJugglerSession(jugglerSessionID); ok { + if info.FrameID == "" || ev.AuxData.FrameID == info.FrameID { + b.mainCtx[jugglerSessionID] = ev.ExecutionContextID + } + } else { + // Session not yet registered — assume main frame + b.mainCtx[jugglerSessionID] = ev.ExecutionContextID + } + b.mainCtxMu.Unlock() + cdpFrameID := b.cdpFrameIDForJugglerSession(jugglerSessionID, ev.AuxData.FrameID) b.emitEvent("Runtime.executionContextCreated", map[string]interface{}{ @@ -528,6 +552,16 @@ func (b *Bridge) SetupEventSubscriptions() { b.ctxMapMu.Unlock() } + // Clear latestCtx if it points to the destroyed context. + // Without this, Runtime.evaluate without contextId would keep + // routing to a destroyed context, causing persistent + // "Failed to find execution context" errors. + b.latestCtxMu.Lock() + if b.latestCtx[jugglerSessionID] == ev.ExecutionContextID { + delete(b.latestCtx, jugglerSessionID) + } + b.latestCtxMu.Unlock() + b.emitEvent("Runtime.executionContextDestroyed", map[string]interface{}{ "executionContextId": numericID, "executionContextUniqueId": ev.ExecutionContextID, diff --git a/pkg/bridge/events_test.go b/pkg/bridge/events_test.go index 5fcccb9..7cbbcba 100644 --- a/pkg/bridge/events_test.go +++ b/pkg/bridge/events_test.go @@ -317,6 +317,11 @@ func TestSetupEventSubscriptions_ExecutionContextDestroyed(t *testing.T) { b.ctxMap[150] = "jug-ctx-1" b.ctxMapMu.Unlock() + // Pre-populate latestCtx to simulate a context that was once "latest" + b.latestCtxMu.Lock() + b.latestCtx["jug-s1"] = "jug-ctx-1" + b.latestCtxMu.Unlock() + mb.mu.Lock() handlers := mb.handlers["Runtime.executionContextDestroyed"] mb.mu.Unlock() @@ -335,6 +340,14 @@ func TestSetupEventSubscriptions_ExecutionContextDestroyed(t *testing.T) { if exists { t.Error("context mapping for 150 should have been removed") } + + // latestCtx should be cleared for the destroyed context + b.latestCtxMu.RLock() + latest := b.latestCtx["jug-s1"] + b.latestCtxMu.RUnlock() + if latest != "" { + t.Errorf("latestCtx for jug-s1 should be empty after context destruction, got %q", latest) + } } func TestSetupEventSubscriptions_AllEventsSubscribed(t *testing.T) { diff --git a/pkg/bridge/runtime.go b/pkg/bridge/runtime.go index ca17fa7..007ade6 100644 --- a/pkg/bridge/runtime.go +++ b/pkg/bridge/runtime.go @@ -32,6 +32,13 @@ func (b *Bridge) handleRuntime(conn *cdp.Connection, msg *cdp.Message) (json.Raw latestCtx := b.latestCtx[jugglerSessionID] b.latestCtxMu.RUnlock() + // Fall back to mainCtx if latestCtx is empty (e.g., subframe was destroyed) + if latestCtx == "" { + b.mainCtxMu.RLock() + latestCtx = b.mainCtx[jugglerSessionID] + b.mainCtxMu.RUnlock() + } + if frameID != "" && latestCtx != "" { ctxID := b.nextCtxID() b.ctxMapMu.Lock() @@ -85,6 +92,14 @@ func (b *Bridge) handleRuntime(conn *cdp.Connection, msg *cdp.Message) (json.Raw latest := b.latestContextForSession(msg.SessionID) if latest != "" { execCtxID = latest + } else { + // Fall back to main frame context when latestCtx is cleared (subframe destruction) + jugglerSessionID := b.resolveSession(msg.SessionID) + b.mainCtxMu.RLock() + if main := b.mainCtx[jugglerSessionID]; main != "" { + execCtxID = main + } + b.mainCtxMu.RUnlock() } // If awaitPromise is requested, wrap the expression so the promise is resolved @@ -147,6 +162,14 @@ func (b *Bridge) handleRuntime(conn *cdp.Connection, msg *cdp.Message) (json.Raw latest := b.latestContextForSession(msg.SessionID) if latest != "" { execCtxID = latest + } else { + // Fall back to main frame context when latestCtx is cleared + jugglerSessionID := b.resolveSession(msg.SessionID) + b.mainCtxMu.RLock() + if main := b.mainCtx[jugglerSessionID]; main != "" { + execCtxID = main + } + b.mainCtxMu.RUnlock() } } @@ -532,7 +555,21 @@ func normalizeRuntimeResult(result json.RawMessage) json.RawMessage { if _, ok := object["type"]; ok { return result } - object["type"] = json.RawMessage(`"undefined"`) + // Infer type from the actual JSON value instead of hardcoding "undefined". + // Juggler omits the type field — we must provide it for CDP clients that check. + // SOURCE: Chrome DevTools Protocol — Runtime.evaluate returns {result:{type,value}} + inferredType := `"undefined"` + if rawVal, ok := object["value"]; ok && string(rawVal) != "null" { + valStr := string(rawVal) + if len(valStr) > 0 && valStr[0] == '"' { + inferredType = `"string"` + } else if valStr == "true" || valStr == "false" { + inferredType = `"boolean"` + } else if valStr[0] >= '0' && valStr[0] <= '9' || valStr[0] == '-' { + inferredType = `"number"` + } + } + object["type"] = json.RawMessage(inferredType) normalized, err := json.Marshal(object) if err != nil { return result diff --git a/pkg/cdp/server.go b/pkg/cdp/server.go index b4f9df9..af77c61 100644 --- a/pkg/cdp/server.go +++ b/pkg/cdp/server.go @@ -61,8 +61,10 @@ func (c *Connection) Send(msg *Message) error { c.writeMu.Lock() defer c.writeMu.Unlock() - // Option 1: Binary framing (more efficient than text) - msgType := websocket.BinaryMessage + // CDP messages are JSON text — use TextMessage (opcode 0x01) unless compressed. + // SOURCE: Chrome DevTools Protocol — wire format is JSON text frames + // BinaryMessage (opcode 0x02) is only used when payload is compressed (flate). + msgType := websocket.TextMessage if c.compress { msgType = websocket.BinaryMessage } @@ -93,7 +95,12 @@ func (c *Connection) SendBatch(msgs []Message) error { c.writeMu.Lock() defer c.writeMu.Unlock() - msgType := websocket.BinaryMessage + // Use TextMessage for uncompressed, BinaryMessage for compressed. + // SOURCE: Chrome DevTools Protocol — wire format is JSON text frames + msgType := websocket.TextMessage + if c.compress { + msgType = websocket.BinaryMessage + } if err := c.ws.WriteMessage(msgType, data); err != nil { return err }