mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
feat(browser): add remote Chrome sidecar support for Docker deployments (#59)
* feat(browser): add remote Chrome sidecar support for Docker deployments
When running in Docker, Chrome is not installed in the runtime image.
This adds support for connecting to a remote Chrome via CDP (Chrome
DevTools Protocol) using a Docker Compose sidecar overlay, following
the existing pattern used by sandbox, OTel, and Tailscale overlays.
Changes:
- Add RemoteURL field to BrowserToolConfig
- Add GOCLAW_BROWSER_REMOTE_URL env var (auto-enables browser tool)
- Browser Manager: remote CDP connection with hostname-to-IP resolution
(required by Chrome M113+ DNS rebinding protection), auto-reconnect
on dead connections, disconnect-only on Stop (sidecar stays alive)
- Auto-start browser on first tool action (no explicit "start" needed)
- Add docker-compose.browser.yml overlay (zenika/alpine-chrome:124)
- Add unit tests for CDP resolution and Manager lifecycle
Usage:
docker compose -f docker-compose.yml -f docker-compose.managed.yml \
-f docker-compose.browser.yml up -d --build
Closes #56
* feat(browser): fix onboard summary and config serialization for remote mode
- onboard.go: show "remote: ws://..." instead of "headless" when RemoteURL is set
- onboard_auto.go: serialize remote_url field in generated config
---------
Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
This commit is contained in:
1 parent
4af4c69d3f
commit
62a6ca9ee5
9 files changed
+481
-32
No files matched your search
+9
-4
@@ -168,12 +168,17 @@ func runGateway() {
|
||||
// Browser automation tool
|
||||
var browserMgr *browser.Manager
|
||||
if cfg.Tools.Browser.Enabled {
|
||||
browserMgr = browser.New(
|
||||
browser.WithHeadless(cfg.Tools.Browser.Headless),
|
||||
)
|
||||
var opts []browser.Option
|
||||
if cfg.Tools.Browser.RemoteURL != "" {
|
||||
opts = append(opts, browser.WithRemoteURL(cfg.Tools.Browser.RemoteURL))
|
||||
slog.Info("browser tool enabled", "remote", cfg.Tools.Browser.RemoteURL)
|
||||
} else {
|
||||
opts = append(opts, browser.WithHeadless(cfg.Tools.Browser.Headless))
|
||||
slog.Info("browser tool enabled", "headless", cfg.Tools.Browser.Headless)
|
||||
}
|
||||
browserMgr = browser.New(opts...)
|
||||
toolsReg.Register(browser.NewBrowserTool(browserMgr))
|
||||
defer browserMgr.Close()
|
||||
slog.Info("browser tool enabled", "headless", cfg.Tools.Browser.Headless)
|
||||
}
|
||||
|
||||
// Web tools (web_search + web_fetch)
|
||||
|
||||
+3
-1
@@ -661,7 +661,9 @@ func runOnboard() {
|
||||
} else {
|
||||
fmt.Println(" Memory: disabled")
|
||||
}
|
||||
if cfg.Tools.Browser.Enabled {
|
||||
if cfg.Tools.Browser.RemoteURL != "" {
|
||||
fmt.Printf(" Browser: enabled (remote: %s)\n", cfg.Tools.Browser.RemoteURL)
|
||||
} else if cfg.Tools.Browser.Enabled {
|
||||
fmt.Println(" Browser: enabled (headless)")
|
||||
} else {
|
||||
fmt.Println(" Browser: disabled")
|
||||
|
||||
+3
-2
@@ -261,8 +261,9 @@ func saveCleanConfig(cfgPath string, cfg *config.Config) error {
|
||||
},
|
||||
},
|
||||
"browser": map[string]interface{}{
|
||||
"enabled": cfg.Tools.Browser.Enabled,
|
||||
"headless": cfg.Tools.Browser.Headless,
|
||||
"enabled": cfg.Tools.Browser.Enabled,
|
||||
"headless": cfg.Tools.Browser.Headless,
|
||||
"remote_url": cfg.Tools.Browser.RemoteURL,
|
||||
},
|
||||
"execApproval": map[string]interface{}{
|
||||
"security": nonEmpty(cfg.Tools.ExecApproval.Security, "full"),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Browser sidecar overlay — adds headless Chrome for the browser automation tool.
|
||||
#
|
||||
# Usage (with managed mode):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.browser.yml up -d --build
|
||||
#
|
||||
# The Chrome sidecar exposes CDP (Chrome DevTools Protocol) on port 9222.
|
||||
# GoClaw connects to it automatically via GOCLAW_BROWSER_REMOTE_URL.
|
||||
|
||||
services:
|
||||
chrome:
|
||||
image: zenika/alpine-chrome:124
|
||||
command:
|
||||
- --no-sandbox
|
||||
- --remote-debugging-address=0.0.0.0
|
||||
- --remote-debugging-port=9222
|
||||
- --remote-allow-origins=*
|
||||
- --disable-gpu
|
||||
- --disable-dev-shm-usage
|
||||
ports:
|
||||
- "${CHROME_CDP_PORT:-9222}:9222"
|
||||
shm_size: 2gb
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9222/json/version >/dev/null 2>&1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '2.0'
|
||||
restart: unless-stopped
|
||||
|
||||
goclaw:
|
||||
environment:
|
||||
- GOCLAW_BROWSER_REMOTE_URL=ws://chrome:9222
|
||||
depends_on:
|
||||
chrome:
|
||||
condition: service_healthy
|
||||
@@ -263,8 +263,9 @@ type WebFetchPolicyConfig struct {
|
||||
|
||||
// BrowserToolConfig controls the browser automation tool.
|
||||
type BrowserToolConfig struct {
|
||||
Enabled bool `json:"enabled"` // enable the browser tool (default false)
|
||||
Headless bool `json:"headless,omitempty"` // run Chrome in headless mode
|
||||
Enabled bool `json:"enabled"` // enable the browser tool (default false)
|
||||
Headless bool `json:"headless,omitempty"` // run Chrome in headless mode (ignored when RemoteURL is set)
|
||||
RemoteURL string `json:"remote_url,omitempty"` // CDP endpoint for remote Chrome sidecar, e.g. "ws://chrome:9222"
|
||||
}
|
||||
|
||||
// ToolPolicySpec defines a tool policy at any level (global, per-agent, per-provider).
|
||||
|
||||
@@ -227,6 +227,12 @@ func (c *Config) applyEnvOverrides() {
|
||||
ensureSandbox()
|
||||
c.Agents.Defaults.Sandbox.NetworkEnabled = v == "true" || v == "1"
|
||||
}
|
||||
|
||||
// Browser (for Docker-compose browser sidecar overlay)
|
||||
envStr("GOCLAW_BROWSER_REMOTE_URL", &c.Tools.Browser.RemoteURL)
|
||||
if c.Tools.Browser.RemoteURL != "" {
|
||||
c.Tools.Browser.Enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// applyContextPruningDefaults auto-enables context pruning when the Anthropic
|
||||
|
||||
+186
-23
@@ -2,8 +2,12 @@ package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -14,13 +18,14 @@ import (
|
||||
|
||||
// Manager handles the Chrome browser lifecycle and page management.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
browser *rod.Browser
|
||||
refs *RefStore
|
||||
pages map[string]*rod.Page // targetID → page
|
||||
console map[string][]ConsoleMessage // targetID → console messages
|
||||
headless bool
|
||||
logger *slog.Logger
|
||||
mu sync.Mutex
|
||||
browser *rod.Browser
|
||||
refs *RefStore
|
||||
pages map[string]*rod.Page // targetID → page
|
||||
console map[string][]ConsoleMessage // targetID → console messages
|
||||
headless bool
|
||||
remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Option configures a Manager.
|
||||
@@ -31,6 +36,12 @@ func WithHeadless(h bool) Option {
|
||||
return func(m *Manager) { m.headless = h }
|
||||
}
|
||||
|
||||
// WithRemoteURL sets a remote CDP endpoint (e.g. "ws://chrome:9222").
|
||||
// When set, Start() connects to the remote Chrome instead of launching locally.
|
||||
func WithRemoteURL(url string) Option {
|
||||
return func(m *Manager) { m.remoteURL = url }
|
||||
}
|
||||
|
||||
// WithLogger sets a custom logger.
|
||||
func WithLogger(l *slog.Logger) Option {
|
||||
return func(m *Manager) { m.logger = l }
|
||||
@@ -50,28 +61,50 @@ func New(opts ...Option) *Manager {
|
||||
return m
|
||||
}
|
||||
|
||||
// Start launches a Chrome browser.
|
||||
// Start launches a local Chrome browser or connects to a remote one.
|
||||
// If already connected but the connection is dead, it reconnects automatically.
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// If browser exists, check if connection is still alive
|
||||
if m.browser != nil {
|
||||
return fmt.Errorf("browser already running")
|
||||
if _, err := m.browser.Pages(); err == nil {
|
||||
return nil // already connected and healthy
|
||||
}
|
||||
// Connection dead — clean up and reconnect
|
||||
m.logger.Info("browser connection lost, reconnecting")
|
||||
m.browser = nil
|
||||
m.pages = make(map[string]*rod.Page)
|
||||
m.console = make(map[string][]ConsoleMessage)
|
||||
}
|
||||
|
||||
l := launcher.New().
|
||||
Headless(m.headless).
|
||||
Set("disable-gpu").
|
||||
Set("no-first-run").
|
||||
Set("no-default-browser-check")
|
||||
var controlURL string
|
||||
|
||||
controlURL, err := l.Launch()
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch Chrome: %w", err)
|
||||
if m.remoteURL != "" {
|
||||
// Remote Chrome sidecar — query /json/version and fix host for Docker networking
|
||||
u, err := resolveRemoteCDP(m.remoteURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve remote Chrome at %s: %w", m.remoteURL, err)
|
||||
}
|
||||
controlURL = u
|
||||
m.logger.Info("connecting to remote Chrome", "cdp", controlURL, "remote", m.remoteURL)
|
||||
} else {
|
||||
// Local Chrome — launch via rod launcher
|
||||
l := launcher.New().
|
||||
Headless(m.headless).
|
||||
Set("disable-gpu").
|
||||
Set("no-first-run").
|
||||
Set("no-default-browser-check")
|
||||
|
||||
u, err := l.Launch()
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch Chrome: %w", err)
|
||||
}
|
||||
controlURL = u
|
||||
m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless)
|
||||
}
|
||||
|
||||
m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless)
|
||||
|
||||
b := rod.New().ControlURL(controlURL)
|
||||
if err := b.Connect(); err != nil {
|
||||
return fmt.Errorf("connect to Chrome: %w", err)
|
||||
@@ -81,7 +114,7 @@ func (m *Manager) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the Chrome browser.
|
||||
// Stop closes the Chrome browser (local) or disconnects (remote sidecar).
|
||||
func (m *Manager) Stop(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -90,7 +123,13 @@ func (m *Manager) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := m.browser.Close()
|
||||
var err error
|
||||
if m.remoteURL == "" {
|
||||
// Local Chrome — close the browser process
|
||||
err = m.browser.Close()
|
||||
}
|
||||
// Remote Chrome — just drop the connection; sidecar stays alive
|
||||
|
||||
m.browser = nil
|
||||
m.pages = make(map[string]*rod.Page)
|
||||
m.console = make(map[string][]ConsoleMessage)
|
||||
@@ -304,8 +343,29 @@ func (m *Manager) Refs() *RefStore {
|
||||
return m.refs
|
||||
}
|
||||
|
||||
// reconnectLocked re-establishes the CDP connection to a remote Chrome.
|
||||
// Must be called with m.mu held. Only works when remoteURL is set.
|
||||
func (m *Manager) reconnectLocked() error {
|
||||
m.browser = nil
|
||||
m.pages = make(map[string]*rod.Page)
|
||||
m.console = make(map[string][]ConsoleMessage)
|
||||
|
||||
controlURL, err := resolveRemoteCDP(m.remoteURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b := rod.New().ControlURL(controlURL)
|
||||
if err := b.Connect(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.browser = b
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPage looks up a page by targetID. If targetID is empty, returns the first available page.
|
||||
// Must be called with m.mu held.
|
||||
// Must be called with m.mu held. If the connection is dead and remoteURL is set,
|
||||
// it attempts one automatic reconnect.
|
||||
func (m *Manager) getPage(targetID string) (*rod.Page, error) {
|
||||
if m.browser == nil {
|
||||
return nil, fmt.Errorf("browser not running")
|
||||
@@ -321,7 +381,19 @@ func (m *Manager) getPage(targetID string) (*rod.Page, error) {
|
||||
// Refresh page list from browser
|
||||
pages, err := m.browser.Pages()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pages: %w", err)
|
||||
// Connection dead — try auto-reconnect for remote Chrome
|
||||
if m.remoteURL != "" {
|
||||
if reconnErr := m.reconnectLocked(); reconnErr != nil {
|
||||
return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr)
|
||||
}
|
||||
m.logger.Info("auto-reconnected to remote Chrome")
|
||||
pages, err = m.browser.Pages()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pages after reconnect: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("list pages: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
@@ -427,3 +499,94 @@ func (m *Manager) getPageAndResolve(targetID, ref string) (*rod.Page, *rod.Eleme
|
||||
func waitStable(page *rod.Page) {
|
||||
_ = page.WaitStable(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
// resolveRemoteCDP queries a Chrome endpoint's /json/version to get the CDP
|
||||
// WebSocket URL, resolving the hostname to an IP address.
|
||||
//
|
||||
// Chrome (M113+) rejects HTTP/WebSocket requests where the Host header is a
|
||||
// hostname (not an IP or "localhost") to prevent DNS rebinding attacks.
|
||||
// In Docker, the service name "chrome" is a hostname, so we resolve it to an
|
||||
// IP address and use that for all connections.
|
||||
|
||||
// cdpHTTPClient is used for /json/version queries with a reasonable timeout.
|
||||
var cdpHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
func resolveRemoteCDP(remoteURL string) (string, error) {
|
||||
parsed, err := url.Parse(remoteURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse remote URL: %w", err)
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
port = "9222"
|
||||
}
|
||||
|
||||
// Resolve hostname to IP — Chrome M113+ requires IP or "localhost" in
|
||||
// the Host header to prevent DNS rebinding attacks.
|
||||
ip, err := resolveToIPv4(host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Query /json/version using the IP (so Host header is an IP).
|
||||
versionURL := fmt.Sprintf("http://%s:%s/json/version", ip, port)
|
||||
resp, err := cdpHTTPClient.Get(versionURL) //nolint:gosec // resolved from user-configured URL
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("query /json/version at %s: %w", versionURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("/json/version returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var ver struct {
|
||||
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ver); err != nil {
|
||||
return "", fmt.Errorf("parse /json/version: %w", err)
|
||||
}
|
||||
if ver.WebSocketDebuggerURL == "" {
|
||||
return "", fmt.Errorf("empty webSocketDebuggerUrl in /json/version response")
|
||||
}
|
||||
|
||||
// Replace host in returned URL with the resolved IP.
|
||||
// Chrome returns ws://127.0.0.1/... but we need ws://<container-IP>:<port>/...
|
||||
wsURL, err := url.Parse(ver.WebSocketDebuggerURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse webSocketDebuggerUrl: %w", err)
|
||||
}
|
||||
wsURL.Host = net.JoinHostPort(ip, port)
|
||||
return wsURL.String(), nil
|
||||
}
|
||||
|
||||
// resolveToIPv4 resolves a hostname to an IPv4 address.
|
||||
// Chrome typically binds on 0.0.0.0 (IPv4), so we prefer IPv4 to avoid
|
||||
// connection failures when DNS returns IPv6 addresses first.
|
||||
// If the host is already an IP, it is returned as-is.
|
||||
func resolveToIPv4(host string) (string, error) {
|
||||
// Already an IP literal — return as-is.
|
||||
if net.ParseIP(host) != nil {
|
||||
return host, nil
|
||||
}
|
||||
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve %s: %w", host, err)
|
||||
}
|
||||
|
||||
// Prefer IPv4.
|
||||
for _, ip := range ips {
|
||||
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return first address (could be IPv6).
|
||||
if len(ips) > 0 {
|
||||
return ips[0], nil
|
||||
}
|
||||
return "", fmt.Errorf("no addresses found for %s", host)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- resolveToIPv4 ---
|
||||
|
||||
func TestResolveToIPv4_IPLiteral(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"127.0.0.1", "127.0.0.1"},
|
||||
{"192.168.1.1", "192.168.1.1"},
|
||||
{"::1", "::1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := resolveToIPv4(tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("resolveToIPv4(%q) unexpected error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveToIPv4(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToIPv4_Localhost(t *testing.T) {
|
||||
ip, err := resolveToIPv4("localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveToIPv4(localhost) error: %v", err)
|
||||
}
|
||||
// Should resolve to 127.0.0.1 (IPv4 preferred)
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
t.Fatalf("resolveToIPv4(localhost) returned non-IP: %q", ip)
|
||||
}
|
||||
if parsed.To4() == nil {
|
||||
t.Logf("resolveToIPv4(localhost) returned IPv6 %q (no IPv4 available)", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToIPv4_UnknownHost(t *testing.T) {
|
||||
_, err := resolveToIPv4("this-host-definitely-does-not-exist.invalid")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown host, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveRemoteCDP ---
|
||||
|
||||
func TestResolveRemoteCDP_Success(t *testing.T) {
|
||||
// Start a fake Chrome /json/version endpoint.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/json/version" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/abc-123",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Extract host:port from test server URL.
|
||||
wsURL := "ws://" + srv.Listener.Addr().String()
|
||||
|
||||
got, err := resolveRemoteCDP(wsURL)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRemoteCDP(%q) error: %v", wsURL, err)
|
||||
}
|
||||
|
||||
// Should contain the devtools path.
|
||||
if !strings.Contains(got, "/devtools/browser/abc-123") {
|
||||
t.Errorf("resolveRemoteCDP result missing devtools path: %q", got)
|
||||
}
|
||||
// Should be a ws:// URL.
|
||||
if !strings.HasPrefix(got, "ws://") {
|
||||
t.Errorf("resolveRemoteCDP result should start with ws://: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemoteCDP_NonOKStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Chrome is not ready"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws://" + srv.Listener.Addr().String()
|
||||
_, err := resolveRemoteCDP(wsURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 status, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 500") {
|
||||
t.Errorf("error should mention HTTP 500: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemoteCDP_EmptyWebSocketURL(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"Browser": "HeadlessChrome/124.0",
|
||||
// webSocketDebuggerUrl intentionally missing
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws://" + srv.Listener.Addr().String()
|
||||
_, err := resolveRemoteCDP(wsURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty webSocketDebuggerUrl, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty webSocketDebuggerUrl") {
|
||||
t.Errorf("error should mention empty URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemoteCDP_InvalidJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("not json"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws://" + srv.Listener.Addr().String()
|
||||
_, err := resolveRemoteCDP(wsURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemoteCDP_ConnectionRefused(t *testing.T) {
|
||||
// Use a port that's definitely not listening.
|
||||
_, err := resolveRemoteCDP("ws://127.0.0.1:1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for connection refused, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemoteCDP_InvalidURL(t *testing.T) {
|
||||
_, err := resolveRemoteCDP("://invalid")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemoteCDP_DefaultPort(t *testing.T) {
|
||||
// Verify that when port is omitted, 9222 is used.
|
||||
// This will fail to connect but the error should reference port 9222.
|
||||
_, err := resolveRemoteCDP("ws://127.0.0.1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error (nothing on 9222), got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "9222") {
|
||||
t.Errorf("error should reference default port 9222: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemoteCDP_HostReplacement(t *testing.T) {
|
||||
// Chrome returns ws://127.0.0.1/... but we need the server's actual IP:port.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/json/version" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Simulate Chrome returning localhost in the WS URL.
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"webSocketDebuggerUrl": "ws://localhost:9999/devtools/browser/xyz",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws://" + srv.Listener.Addr().String()
|
||||
got, err := resolveRemoteCDP(wsURL)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRemoteCDP(%q) error: %v", wsURL, err)
|
||||
}
|
||||
|
||||
// The host in the result should be the test server's address, NOT localhost:9999.
|
||||
if strings.Contains(got, "localhost:9999") {
|
||||
t.Errorf("host should be replaced but still has localhost:9999: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "/devtools/browser/xyz") {
|
||||
t.Errorf("path should be preserved: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Manager options ---
|
||||
|
||||
func TestManagerOptions(t *testing.T) {
|
||||
m := New(
|
||||
WithHeadless(true),
|
||||
WithRemoteURL("ws://chrome:9222"),
|
||||
)
|
||||
if !m.headless {
|
||||
t.Error("WithHeadless(true) not applied")
|
||||
}
|
||||
if m.remoteURL != "ws://chrome:9222" {
|
||||
t.Errorf("WithRemoteURL not applied: %q", m.remoteURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerStopWhenNil(t *testing.T) {
|
||||
m := New()
|
||||
// Stop on a fresh manager should be a no-op.
|
||||
if err := m.Close(); err != nil {
|
||||
t.Errorf("Close() on nil browser should be nil, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerStatusWhenStopped(t *testing.T) {
|
||||
m := New()
|
||||
status := m.Status()
|
||||
if status.Running {
|
||||
t.Error("Status.Running should be false when browser is nil")
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,14 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]interface{})
|
||||
return tools.ErrorResult("action is required")
|
||||
}
|
||||
|
||||
// Auto-start browser for actions that need it
|
||||
switch action {
|
||||
case "open", "snapshot", "screenshot", "navigate", "act", "tabs", "console":
|
||||
if err := t.manager.Start(ctx); err != nil {
|
||||
return tools.ErrorResult(fmt.Sprintf("failed to start browser: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "status":
|
||||
return t.handleStatus()
|
||||
|
||||
Reference in new issue
Block a user