Skip to content
Closed
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
155 changes: 155 additions & 0 deletions internal/proxy/bridge_live_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package proxy

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"

chromedp "github.com/chromedp/chromedp"
)

// TestContentToShellBridge_Live is an opt-in end-to-end check that content-frame
// fetch/XHR calls and JS/console errors are forwarded up to the chrome shell, so
// the indicator (which runs in the shell window) sees them. Gated on
// AGNT_LIVE_BRIDGE because it launches a real Chrome.
//
// AGNT_LIVE_BRIDGE=1 go test ./internal/proxy -run TestContentToShellBridge_Live -v
func TestContentToShellBridge_Live(t *testing.T) {
if os.Getenv("AGNT_LIVE_BRIDGE") == "" {
t.Skip("set AGNT_LIVE_BRIDGE=1 to run the live browser bridge check")
}

// Backend app: a full HTML document (so the proxy applies the always-wrap
// shell model) that on load issues a fetch, logs a console error, and throws
// an uncaught error.
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/data" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
return
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<!DOCTYPE html><html><head><title>app</title></head><body>
<h1>app</h1>
<script>
fetch('/api/data').then(function(r){return r.json();}).catch(function(){});
console.error('CERR_MARKER');
setTimeout(function(){ throw new Error('JSERR_MARKER'); }, 50);
</script>
</body></html>`))
}))
defer backend.Close()

ps, err := NewProxyServer(ProxyConfig{
ID: "live-bridge",
TargetURL: backend.URL,
ListenPort: 0, // auto-assign
})
if err != nil {
t.Fatalf("NewProxyServer: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := ps.Start(ctx); err != nil {
t.Fatalf("proxy Start: %v", err)
}
defer ps.Stop(context.Background())

proxyURL := "http://" + ps.ListenAddr + "/"

// Wait for the proxy to serve.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
resp, e := http.Get(proxyURL)
if e == nil {
resp.Body.Close()
break
}
time.Sleep(100 * time.Millisecond)
}

// Launch Chrome.
execPath := os.Getenv("AGNT_CHROME")
if execPath == "" {
execPath = "google-chrome"
}
allocOpts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.ExecPath(execPath),
chromedp.Headless,
chromedp.DisableGPU,
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.NoFirstRun,
chromedp.NoDefaultBrowserCheck,
)
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), allocOpts...)
defer allocCancel()
bctx, bcancel := chromedp.NewContext(allocCtx)
defer bcancel()
runCtx, runCancel := context.WithTimeout(bctx, 30*time.Second)
defer runCancel()

// Evaluate in the TOP (shell) frame: read the shell's own buffers, which are
// only non-empty if the content frame forwarded its captures up.
var raw string
expr := `(function(){
if(!window.__devtool_api||!window.__devtool_errors){return 'NO_API role='+(window.__devtool_frame_role||'?');}
var calls=window.__devtool_api.getCalls();
return JSON.stringify({
role: window.__devtool_frame_role||'?',
urls: calls.map(function(c){return c.url;}),
stats: window.__devtool_errors.getStats()
});
})()`

if err := chromedp.Run(runCtx,
chromedp.Navigate(proxyURL),
chromedp.Sleep(3*time.Second),
chromedp.Evaluate(expr, &raw),
); err != nil {
t.Fatalf("chromedp run: %v", err)
}

t.Logf("shell-frame state: %s", raw)

if strings.HasPrefix(raw, "NO_API") {
t.Fatalf("shell frame had no devtool API: %s", raw)
}

var got struct {
Role string `json:"role"`
URLs []string `json:"urls"`
Stats struct {
JSErrorCount int `json:"jsErrorCount"`
ConsoleErrorCount int `json:"consoleErrorCount"`
TotalCount int `json:"totalCount"`
} `json:"stats"`
}
if err := json.Unmarshal([]byte(raw), &got); err != nil {
t.Fatalf("unmarshal %q: %v", raw, err)
}

if got.Role != "chrome" {
t.Errorf("top frame role = %q, want chrome (proxy not applying shell wrap?)", got.Role)
}

var sawAPI bool
for _, u := range got.URLs {
if strings.Contains(u, "/api/data") {
sawAPI = true
}
}
if !sawAPI {
t.Errorf("shell Network buffer missing /api/data; urls=%v", got.URLs)
}
if got.Stats.JSErrorCount < 1 {
t.Errorf("shell Errors buffer missing JS error; stats=%+v", got.Stats)
}
if got.Stats.ConsoleErrorCount < 1 {
t.Errorf("shell Errors buffer missing console error; stats=%+v", got.Stats)
}
}
33 changes: 30 additions & 3 deletions internal/proxy/scripts/api-tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@
if (callBuffer.length > MAX_ENTRIES) {
callBuffer.shift();
}
// The proxy renders a top-level navigation as a chrome shell wrapping the
// real page in a content <iframe>. The indicator UI runs in the shell, but
// fetch/XHR happen in the content frame — so the shell's own buffer is
// empty. Forward each captured call up to the shell (same-origin direct
// reach; the proxy serves both frames) so its Network tab + badge update.
// Best-effort; never let a forwarding failure break capture.
if (window.__devtool_frame_role === 'content') {
try {
var shell = window.parent;
if (shell && shell !== window && typeof shell.__devtool_api_ingest === 'function') {
shell.__devtool_api_ingest(call);
}
} catch (e) { /* cross-origin / shell gone */ }
}
}

/**
Expand Down Expand Up @@ -79,15 +93,15 @@
} catch (e) {}
}).catch(function() {});
}
addCall(call);
if (window.__devtool_frame_role !== 'chrome') { addCall(call); }
return response;
})
.catch(function(error) {
call.status = 0;
call.ok = false;
call.duration = Date.now() - startTime;
call.error = error.message || 'Network error';
addCall(call);
if (window.__devtool_frame_role !== 'chrome') { addCall(call); }
throw error;
});
};
Expand Down Expand Up @@ -135,7 +149,7 @@
} catch (e) {}
} catch (e) {}
}
addCall(call);
if (window.__devtool_frame_role !== 'chrome') { addCall(call); }
};

xhr.addEventListener('loadend', onLoadEnd);
Expand Down Expand Up @@ -342,6 +356,19 @@
return { buckets: buckets, window: windowSec, maxBucket: maxBucket };
}

// Shell-side ingest: content frames forward their captured calls here (see
// addCall) because the indicator runs in the chrome shell — a separate window
// from the content frame where fetch/XHR actually happen. Terminal sink: does
// not re-forward (the shell has no parent shell), so no loop. Defined in every
// frame for simplicity; only ever invoked on the shell by its content child.
window.__devtool_api_ingest = function(call) {
if (!call) { return; }
callBuffer.push(call);
if (callBuffer.length > MAX_ENTRIES) {
callBuffer.shift();
}
};

// Export API
window.__devtool_api = {
getCalls: getCalls,
Expand Down
38 changes: 38 additions & 0 deletions internal/proxy/scripts/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,36 @@
var consoleErrorBuffer = [];
var consoleWarningBuffer = [];

// The proxy wraps a top-level navigation in a chrome shell whose body is a
// content <iframe> with the real page. Error capture (setupErrorTrackingWith
// Buffer) runs only in the content frame, but the indicator's Errors tab
// reads window.__devtool_errors in the SHELL — a separate window. Forward
// each buffered error up to the shell (same-origin direct reach; the proxy
// serves both frames) so the shell's buffers, and thus the Errors tab +
// badge, reflect the page. kind is 'js' | 'console' | 'warning'.
function forwardErrorToShell(kind, entry) {
if (window.__devtool_frame_role !== 'content') { return; }
try {
var shell = window.parent;
if (shell && shell !== window && typeof shell.__devtool_errors_ingest === 'function') {
shell.__devtool_errors_ingest(kind, entry);
}
} catch (e) { /* cross-origin / shell gone — best-effort */ }
}

// Shell-side sink for errors forwarded by content frames. Terminal: pushes
// into the shell's own per-kind buffers (does not re-forward), so getDedup-
// licatedErrors / getStats / the Errors tab all see content-frame errors
// unchanged. Deliberately does NOT feed the consolidated stream
// (addConsolidatedError) — no shell-side consumer reads getConsolidated*
// today. If one is added, forwarded errors must also land there.
function ingestForwardedError(kind, entry) {
if (!entry) { return; }
if (kind === 'js') { addToBuffer(jsErrorBuffer, entry); }
else if (kind === 'console') { addToBuffer(consoleErrorBuffer, entry); }
else if (kind === 'warning') { addToBuffer(consoleWarningBuffer, entry); }
}

// Consolidated error stream - combines errors from all sources
// Sources: proxy (server-side errors), js (JavaScript errors), console (console.error),
// http (HTTP errors like 4xx/5xx), process (stdout/stderr from processes)
Expand Down Expand Up @@ -686,6 +716,7 @@
};

addToBuffer(consoleErrorBuffer, entry);
forwardErrorToShell('console', entry);

// Add to consolidated error stream
addConsolidatedError({
Expand Down Expand Up @@ -738,6 +769,7 @@
};

addToBuffer(consoleWarningBuffer, entry);
forwardErrorToShell('warning', entry);

// Add to consolidated error stream
addConsolidatedError({
Expand Down Expand Up @@ -798,6 +830,7 @@
};

addToBuffer(jsErrorBuffer, entry);
forwardErrorToShell('js', entry);

// Add to consolidated error stream
addConsolidatedError({
Expand Down Expand Up @@ -1268,6 +1301,11 @@

// Export error tracking API for diagnostics panel
try {
// Shell-side ingest sink for errors forwarded by content frames. Exported
// unconditionally (every frame) — only the shell is ever called, by its
// content child via window.parent.__devtool_errors_ingest. See
// forwardErrorToShell / ingestForwardedError above.
window.__devtool_errors_ingest = ingestForwardedError;
if (!window.__devtool_errors) {
window.__devtool_errors = {
getJSErrors: function() {
Expand Down
Loading