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..a96bb14 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, @@ -649,20 +683,46 @@ func (b *Bridge) SetupEventSubscriptions() { }, cdpSessionID) }) - // Network.requestWillBeSent → Network.requestWillBeSent + // Network.requestWillBeSent → Network.requestWillBeSent (and Fetch.requestPaused if intercepted) + // SOURCE: Juggler NetworkObserver.js — _sendOnRequest sends headers as [{name, value}] array b.backend.Subscribe("Network.requestWillBeSent", func(jugglerSessionID string, params json.RawMessage) { - var ev struct { - RequestID string `json:"requestId"` - FrameID string `json:"frameId"` - URL string `json:"url"` - Method string `json:"method"` - Headers map[string]string `json:"headers"` - IsNavigation bool `json:"isNavigationRequest"` - RedirectURL string `json:"redirectedFrom"` - } - if err := json.Unmarshal(params, &ev); err != nil { + // Juggler sends headers as [{name:"Host",value:"github.com"}], not as a map. + // Unmarshal into raw struct first, then convert headers to map. + var raw struct { + RequestID string `json:"requestId"` + FrameID string `json:"frameId"` + URL string `json:"url"` + Method string `json:"method"` + Headers []struct{ Name string; Value string } `json:"headers"` + IsNavigation bool `json:"isNavigationRequest"` + RedirectURL string `json:"redirectedFrom"` + IsIntercepted bool `json:"isIntercepted"` + } + if err := json.Unmarshal(params, &raw); err != nil { return } + ev := struct { + RequestID string + FrameID string + URL string + Method string + Headers map[string]string + IsNavigation bool + RedirectURL string + IsIntercepted bool + }{ + RequestID: raw.RequestID, + FrameID: raw.FrameID, + URL: raw.URL, + Method: raw.Method, + IsNavigation: raw.IsNavigation, + RedirectURL: raw.RedirectURL, + IsIntercepted: raw.IsIntercepted, + Headers: make(map[string]string, len(raw.Headers)), + } + for _, h := range raw.Headers { + ev.Headers[h.Name] = h.Value + } cdpSessionID := b.resolveCDPSession(jugglerSessionID) cdpFrameID := b.cdpFrameIDForJugglerSession(jugglerSessionID, ev.FrameID) @@ -690,6 +750,46 @@ func (b *Bridge) SetupEventSubscriptions() { resourceType = "Other" } + // If request is intercepted, emit Fetch.requestPaused BEFORE Network.requestWillBeSent. + // Juggler uses Network.requestWillBeSent { isIntercepted: true } instead of a separate + // Browser.requestIntercepted event (which doesn't exist in Juggler). + if ev.IsIntercepted { + log.Printf("[event] Network.requestWillBeSent (intercepted) → Fetch.requestPaused requestId=%s url=%s cdpSession=%s", ev.RequestID, ev.URL, cdpSessionID) + + b.emitEvent("Network.requestWillBeSent", map[string]interface{}{ + "requestId": ev.RequestID, + "loaderId": ev.RequestID, + "documentURL": ev.URL, + "request": map[string]interface{}{ + "url": ev.URL, + "method": ev.Method, + "headers": cdpHeaders, + "initialPriority": "High", + "referrerPolicy": "strict-origin-when-cross-origin", + }, + "timestamp": 0, + "wallTime": 0, + "initiator": map[string]interface{}{"type": "other"}, + "type": resourceType, + "frameId": cdpFrameID, + }, cdpSessionID) + + b.emitEvent("Fetch.requestPaused", map[string]interface{}{ + "requestId": ev.RequestID, + "networkId": ev.RequestID, + "request": map[string]interface{}{ + "url": ev.URL, + "method": ev.Method, + "headers": cdpHeaders, + "initialPriority": "High", + "referrerPolicy": "strict-origin-when-cross-origin", + }, + "frameId": cdpFrameID, + "resourceType": resourceType, + }, cdpSessionID) + return + } + b.emitEvent("Network.requestWillBeSent", map[string]interface{}{ "requestId": ev.RequestID, "loaderId": ev.RequestID, @@ -867,117 +967,13 @@ func (b *Bridge) SetupEventSubscriptions() { }) // Browser.requestIntercepted → Fetch.requestPaused - b.backend.Subscribe("Browser.requestIntercepted", func(jugglerSessionID string, params json.RawMessage) { - var ev struct { - RequestID string `json:"requestId"` - // Juggler sends request fields at top level (not nested in "request") - URL string `json:"url"` - Method string `json:"method"` - Headers []struct { - Name string `json:"name"` - Value string `json:"value"` - } `json:"headers"` - // Nested format for backwards compatibility - Request struct { - URL string `json:"url"` - Method string `json:"method"` - Headers map[string]string `json:"headers"` - } `json:"request"` - FrameID string `json:"frameId"` - IsNavigationRequest bool `json:"isNavigationRequest"` - ResourceType string `json:"resourceType"` - } - if err := json.Unmarshal(params, &ev); err != nil { - log.Printf("events: failed to parse Browser.requestIntercepted: %v", err) - return - } - - cdpSessionID := b.resolveCDPSession(jugglerSessionID) - - // Browser.requestIntercepted is a browser-level event (no juggler session ID). - // Resolve the CDP session from the frameId so Puppeteer receives it on the page session. - if cdpSessionID == "" && ev.FrameID != "" { - if info, ok := b.sessions.GetByFrameID(ev.FrameID); ok { - cdpSessionID = info.SessionID - } - } - - // Last resort: find any page session to deliver the event - if cdpSessionID == "" { - for _, info := range b.sessions.All() { - if info.Type == "page" { - cdpSessionID = info.SessionID - break - } - } - } - - // Use top-level fields (new Juggler format) or nested request fields (fallback) - url := ev.URL - method := ev.Method - if url == "" { - url = ev.Request.URL - method = ev.Request.Method - } - - // Convert headers array [{name,value}] to map for CDP - headerMap := map[string]string{} - for _, h := range ev.Headers { - headerMap[h.Name] = h.Value - } - if len(headerMap) == 0 { - headerMap = ev.Request.Headers - } - - resourceType := ev.ResourceType - if resourceType == "" { - resourceType = "Other" - if ev.IsNavigationRequest { - resourceType = "Document" - } - } - - cdpFrameID := ev.FrameID - if cdpSessionID != "" { - cdpFrameID = b.cdpFrameIDForSession(cdpSessionID, ev.FrameID) - } - - log.Printf("[event] Browser.requestIntercepted → Fetch.requestPaused requestId=%s url=%s cdpSession=%s", ev.RequestID, url, cdpSessionID) - - // Emit Network.requestWillBeSent BEFORE Fetch.requestPaused. - // Puppeteer needs both events with matching requestId/networkId to process interception. - b.emitEvent("Network.requestWillBeSent", map[string]interface{}{ - "requestId": ev.RequestID, - "loaderId": ev.RequestID, - "documentURL": url, - "request": map[string]interface{}{ - "url": url, - "method": method, - "headers": headerMap, - "initialPriority": "High", - "referrerPolicy": "strict-origin-when-cross-origin", - }, - "timestamp": 0, - "wallTime": 0, - "initiator": map[string]interface{}{"type": "other"}, - "type": resourceType, - "frameId": cdpFrameID, - }, cdpSessionID) - - b.emitEvent("Fetch.requestPaused", map[string]interface{}{ - "requestId": ev.RequestID, - "networkId": ev.RequestID, - "request": map[string]interface{}{ - "url": url, - "method": method, - "headers": headerMap, - "initialPriority": "High", - "referrerPolicy": "strict-origin-when-cross-origin", - }, - "frameId": cdpFrameID, - "resourceType": resourceType, - }, cdpSessionID) - }) + // REMOVED: Juggler does NOT emit Browser.requestIntercepted events. + // Instead, Juggler uses Network.requestWillBeSent with isIntercepted: true. + // The isIntercepted check is now handled in the Network.requestWillBeSent handler above. + // + // b.backend.Subscribe("Browser.requestIntercepted", func(jugglerSessionID string, params json.RawMessage) { + // ... (old handler code) + // }) } // emitTabAttach emits the tab-level attachment on the browser session. diff --git a/pkg/bridge/events_test.go b/pkg/bridge/events_test.go index 5fcccb9..6347883 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) { @@ -358,7 +371,6 @@ func TestSetupEventSubscriptions_AllEventsSubscribed(t *testing.T) { "Network.responseReceived", "Network.requestFinished", "Network.requestFailed", - "Browser.requestIntercepted", "Page.webSocketCreated", "Page.webSocketOpened", "Page.webSocketClosed", @@ -519,6 +531,64 @@ func TestSetupEventSubscriptions_ScreencastFrame(t *testing.T) { handlers[0]("jug-s1", params) } +func TestSetupEventSubscriptions_RequestWillBeSent_Intercepted(t *testing.T) { + b, mb := newTestBridge() + b.SetupEventSubscriptions() + + // Register a session so resolveCDPSession works + b.sessions.Add(&cdp.SessionInfo{ + SessionID: "cdp-s1", + JugglerSessionID: "jug-s1", + TargetID: "t1", + }) + + mb.mu.Lock() + handlers := mb.handlers["Network.requestWillBeSent"] + mb.mu.Unlock() + + if len(handlers) == 0 { + t.Fatal("no handler registered for Network.requestWillBeSent") + } + + // Fire with isIntercepted: true — should emit Fetch.requestPaused + Network.requestWillBeSent + params := json.RawMessage(`{ + "requestId": "req-1", + "url": "https://example.com/intercepted", + "method": "GET", + "headers": {"User-Agent": "test"}, + "isNavigationRequest": true, + "isIntercepted": true, + "frameId": "frame-1" + }`) + handlers[0]("jug-s1", params) + + // Fire with isIntercepted: false — should emit only Network.requestWillBeSent + paramsNotIntercepted := json.RawMessage(`{ + "requestId": "req-2", + "url": "https://example.com/normal", + "method": "GET", + "headers": {}, + "isNavigationRequest": false, + "isIntercepted": false, + "frameId": "frame-2" + }`) + handlers[0]("jug-s1", paramsNotIntercepted) +} + +func TestSetupEventSubscriptions_BrowserRequestIntercepted_NotSubscribed(t *testing.T) { + b, mb := newTestBridge() + _ = b + b.SetupEventSubscriptions() + + mb.mu.Lock() + handlers := mb.handlers["Browser.requestIntercepted"] + mb.mu.Unlock() + + if len(handlers) != 0 { + t.Error("Browser.requestIntercepted should not be subscribed (Juggler does not emit this event)") + } +} + func TestSetupEventSubscriptions_FileChooserOpened(t *testing.T) { b, mb := newTestBridge() b.SetupEventSubscriptions() diff --git a/pkg/bridge/fetch.go b/pkg/bridge/fetch.go index 4e3200a..f2f1a92 100644 --- a/pkg/bridge/fetch.go +++ b/pkg/bridge/fetch.go @@ -19,32 +19,22 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe json.Unmarshal(msg.Params, ¶ms) } - jugglerParams := map[string]interface{}{ + // Network.setRequestInterception is a PAGE-level handler in Juggler. + // It only accepts {enabled: Boolean} — no browserContextId. + // SOURCE: Juggler PageHandler.js — Network.setRequestInterception + // SOURCE: Juggler Protocol.js — Network.setRequestInterception params: {enabled: Boolean} + _, err := b.callJuggler(msg.SessionID, "Network.setRequestInterception", map[string]interface{}{ "enabled": true, - } - if msg.SessionID != "" { - if info, ok := b.sessions.Get(msg.SessionID); ok { - b.setJugglerBrowserContext(jugglerParams, info.BrowserContextID) - } - } - - _, err := b.callJuggler("", "Browser.setRequestInterception", jugglerParams) + }) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } return json.RawMessage(`{}`), nil case "Fetch.disable": - jugglerParams := map[string]interface{}{ + _, err := b.callJuggler(msg.SessionID, "Network.setRequestInterception", map[string]interface{}{ "enabled": false, - } - if msg.SessionID != "" { - if info, ok := b.sessions.Get(msg.SessionID); ok { - b.setJugglerBrowserContext(jugglerParams, info.BrowserContextID) - } - } - - _, err := b.callJuggler("", "Browser.setRequestInterception", jugglerParams) + }) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -81,7 +71,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe jugglerParams["headers"] = headers } - _, err := b.callJuggler("", "Browser.continueInterceptedRequest", jugglerParams) + _, err := b.callJuggler("", "Network.resumeInterceptedRequest", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -123,7 +113,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe "body": params.Body, } - _, err := b.callJuggler("", "Browser.fulfillInterceptedRequest", jugglerParams) + _, err := b.callJuggler("", "Network.fulfillInterceptedRequest", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -146,7 +136,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe "errorCode": errorCode, } - _, err := b.callJuggler("", "Browser.abortInterceptedRequest", jugglerParams) + _, err := b.callJuggler("", "Network.abortInterceptedRequest", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -194,7 +184,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe return nil, &cdp.Error{Code: -32602, Message: "invalid params"} } - result, err := b.callJuggler("", "Browser.getResponseBody", map[string]interface{}{ + result, err := b.callJuggler("", "Network.getResponseBody", map[string]interface{}{ "requestId": params.RequestID, }) if err != nil { @@ -253,7 +243,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe jugglerParams["headers"] = headers } - _, err := b.callJuggler("", "Browser.continueInterceptedRequest", jugglerParams) + _, err := b.callJuggler("", "Network.resumeInterceptedRequest", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } diff --git a/pkg/bridge/fetch_test.go b/pkg/bridge/fetch_test.go index 5a46cf2..a1bca69 100644 --- a/pkg/bridge/fetch_test.go +++ b/pkg/bridge/fetch_test.go @@ -29,8 +29,8 @@ func TestFetchEnable(t *testing.T) { if err != nil { t.Fatal(err) } - if last.Method != "Browser.setRequestInterception" { - t.Errorf("method = %q, want Browser.setRequestInterception", last.Method) + if last.Method != "Network.setRequestInterception" { + t.Errorf("method = %q, want Network.setRequestInterception", last.Method) } var params map[string]interface{} @@ -60,10 +60,8 @@ func TestFetchEnableWithSession(t *testing.T) { } last, _ := mb.LastCall() - var params map[string]interface{} - json.Unmarshal(last.Params, ¶ms) - if params["browserContextId"] != "ctx-1" { - t.Errorf("browserContextId = %v, want ctx-1", params["browserContextId"]) + if last.Method != "Network.setRequestInterception" { + t.Errorf("method = %q, want Network.setRequestInterception", last.Method) } } @@ -84,8 +82,8 @@ func TestFetchDisable(t *testing.T) { } last, _ := mb.LastCall() - if last.Method != "Browser.setRequestInterception" { - t.Errorf("method = %q, want Browser.setRequestInterception", last.Method) + if last.Method != "Network.setRequestInterception" { + t.Errorf("method = %q, want Network.setRequestInterception", last.Method) } var params map[string]interface{} @@ -115,10 +113,13 @@ func TestFetchDisableWithSession(t *testing.T) { } last, _ := mb.LastCall() + if last.Method != "Network.setRequestInterception" { + t.Errorf("method = %q, want Network.setRequestInterception", last.Method) + } var params map[string]interface{} json.Unmarshal(last.Params, ¶ms) - if params["browserContextId"] != "ctx-2" { - t.Errorf("browserContextId = %v, want ctx-2", params["browserContextId"]) + if params["enabled"] != false { + t.Errorf("enabled = %v, want false", params["enabled"]) } } @@ -146,8 +147,8 @@ func TestFetchContinueRequest_HeaderConversion(t *testing.T) { } last, _ := mb.LastCall() - if last.Method != "Browser.continueInterceptedRequest" { - t.Errorf("method = %q, want Browser.continueInterceptedRequest", last.Method) + if last.Method != "Network.resumeInterceptedRequest" { + t.Errorf("method = %q, want Network.resumeInterceptedRequest", last.Method) } var params map[string]interface{} @@ -276,8 +277,8 @@ func TestFetchFulfillRequest(t *testing.T) { } last, _ := mb.LastCall() - if last.Method != "Browser.fulfillInterceptedRequest" { - t.Errorf("method = %q, want Browser.fulfillInterceptedRequest", last.Method) + if last.Method != "Network.fulfillInterceptedRequest" { + t.Errorf("method = %q, want Network.fulfillInterceptedRequest", last.Method) } var params map[string]interface{} @@ -413,8 +414,8 @@ func TestFetchFailRequest_AllErrorReasons(t *testing.T) { } last, _ := mb.LastCall() - if last.Method != "Browser.abortInterceptedRequest" { - t.Errorf("method = %q, want Browser.abortInterceptedRequest", last.Method) + if last.Method != "Network.abortInterceptedRequest" { + t.Errorf("method = %q, want Network.abortInterceptedRequest", last.Method) } var p map[string]interface{} @@ -459,7 +460,7 @@ func TestFetchGetResponseBody_Base64(t *testing.T) { b64 := base64.StdEncoding.EncodeToString(binaryData) resp, _ := json.Marshal(map[string]string{"base64body": b64}) - mb.SetResponse("", "Browser.getResponseBody", resp, nil) + mb.SetResponse("", "Network.getResponseBody", resp, nil) msg := &cdp.Message{ ID: 1, @@ -493,7 +494,7 @@ func TestFetchGetResponseBody_UTF8Text(t *testing.T) { b64 := base64.StdEncoding.EncodeToString([]byte(textData)) resp, _ := json.Marshal(map[string]string{"base64body": b64}) - mb.SetResponse("", "Browser.getResponseBody", resp, nil) + mb.SetResponse("", "Network.getResponseBody", resp, nil) msg := &cdp.Message{ ID: 1, diff --git a/pkg/bridge/network.go b/pkg/bridge/network.go index c7d1441..2cc615d 100644 --- a/pkg/bridge/network.go +++ b/pkg/bridge/network.go @@ -120,16 +120,13 @@ func (b *Bridge) handleNetwork(conn *cdp.Connection, msg *cdp.Message) (json.Raw json.Unmarshal(msg.Params, ¶ms) } - jugglerParams := map[string]interface{}{ + // Network.setRequestInterception is a PAGE-level handler in Juggler. + // Pass through directly to Juggler's Network.setRequestInterception. + // SOURCE: Juggler PageHandler.js — Network.setRequestInterception + // SOURCE: Juggler Protocol.js — Network.setRequestInterception params: {enabled: Boolean} + _, err := b.callJuggler(msg.SessionID, "Network.setRequestInterception", map[string]interface{}{ "enabled": len(params.Patterns) > 0, - } - if msg.SessionID != "" { - if info, ok := b.sessions.Get(msg.SessionID); ok { - b.setJugglerBrowserContext(jugglerParams, info.BrowserContextID) - } - } - - _, err := b.callJuggler("", "Browser.setRequestInterception", jugglerParams) + }) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } diff --git a/pkg/bridge/network_test.go b/pkg/bridge/network_test.go index 8369ce1..ac4ca4e 100644 --- a/pkg/bridge/network_test.go +++ b/pkg/bridge/network_test.go @@ -365,8 +365,8 @@ func TestNetworkSetRequestInterception_WithPatterns(t *testing.T) { } last, _ := mb.LastCall() - if last.Method != "Browser.setRequestInterception" { - t.Errorf("method = %q, want Browser.setRequestInterception", last.Method) + if last.Method != "Network.setRequestInterception" { + t.Errorf("method = %q, want Network.setRequestInterception", last.Method) } var params map[string]interface{} @@ -392,6 +392,10 @@ func TestNetworkSetRequestInterception_EmptyPatterns(t *testing.T) { } last, _ := mb.LastCall() + if last.Method != "Network.setRequestInterception" { + t.Errorf("method = %q, want Network.setRequestInterception", last.Method) + } + var params map[string]interface{} json.Unmarshal(last.Params, ¶ms) 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 }