Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pkg/bridge/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
36 changes: 35 additions & 1 deletion pkg/bridge/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand All @@ -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
Expand Down Expand Up @@ -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{}{
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions pkg/bridge/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) {
Expand Down
39 changes: 38 additions & 1 deletion pkg/bridge/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions pkg/cdp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down