feat: add local cdp startup and external association
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chub/internal/domain"
|
||||
)
|
||||
|
||||
const remoteDebugPortAttempts = 100
|
||||
|
||||
var (
|
||||
ErrRemoteDebugPortUnavailable = errors.New("remote debugging port is unavailable")
|
||||
ErrRemoteDebugEndpointUnavailable = errors.New("remote debugging endpoint is unavailable")
|
||||
)
|
||||
|
||||
type RemoteDebugEndpoint struct {
|
||||
Port int
|
||||
Browser string
|
||||
}
|
||||
|
||||
type RemoteDebugEndpointInspector interface {
|
||||
InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
WaitForRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
InspectRemoteDebugPort(context.Context, domain.BrowserKind, int) (RemoteDebugEndpoint, error)
|
||||
WaitForRemoteDebugPort(context.Context, domain.BrowserKind, int) (RemoteDebugEndpoint, error)
|
||||
}
|
||||
|
||||
type RemoteDebugPortAllocator interface {
|
||||
FindAvailableRemoteDebugPort(context.Context, int) (int, error)
|
||||
}
|
||||
|
||||
type CDPInspector struct {
|
||||
readFile func(string) ([]byte, error)
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewCDPInspector() *CDPInspector {
|
||||
return &CDPInspector{
|
||||
readFile: os.ReadFile,
|
||||
client: &http.Client{Transport: &http.Transport{Proxy: nil}},
|
||||
}
|
||||
}
|
||||
|
||||
func (i *CDPInspector) InspectRemoteDebugEndpoint(ctx context.Context, kind domain.BrowserKind, userDataDir string) (RemoteDebugEndpoint, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RemoteDebugEndpoint{}, err
|
||||
}
|
||||
if i == nil || i.readFile == nil || i.client == nil || !kind.Valid() {
|
||||
return RemoteDebugEndpoint{}, ErrRemoteDebugEndpointUnavailable
|
||||
}
|
||||
directory := strings.TrimSpace(userDataDir)
|
||||
if directory == "" || !filepath.IsAbs(directory) {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: user data dir must be absolute", domain.ErrInvalidLaunchSpec)
|
||||
}
|
||||
data, err := i.readFile(filepath.Join(filepath.Clean(directory), "DevToolsActivePort"))
|
||||
if err != nil {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: read active port", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
port, err := parseDevToolsActivePort(data)
|
||||
if err != nil {
|
||||
return RemoteDebugEndpoint{}, err
|
||||
}
|
||||
return i.InspectRemoteDebugPort(ctx, kind, port)
|
||||
}
|
||||
|
||||
func (i *CDPInspector) InspectRemoteDebugPort(ctx context.Context, kind domain.BrowserKind, port int) (RemoteDebugEndpoint, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RemoteDebugEndpoint{}, err
|
||||
}
|
||||
if i == nil || i.client == nil || !kind.Valid() || !domain.ValidRemoteDebugPort(port) {
|
||||
return RemoteDebugEndpoint{}, ErrRemoteDebugEndpointUnavailable
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/json/version", nil)
|
||||
if err != nil {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: build version request", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
response, err := i.client.Do(request)
|
||||
if err != nil {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: query version", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: version status %d", ErrRemoteDebugEndpointUnavailable, response.StatusCode)
|
||||
}
|
||||
var version struct {
|
||||
Browser string `json:"Browser"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&version); err != nil {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: decode version", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if !browserVersionMatchesKind(version.Browser, kind) {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: browser kind mismatch", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
return RemoteDebugEndpoint{Port: port, Browser: version.Browser}, nil
|
||||
}
|
||||
|
||||
func (i *CDPInspector) WaitForRemoteDebugEndpoint(ctx context.Context, kind domain.BrowserKind, userDataDir string) (RemoteDebugEndpoint, error) {
|
||||
return waitForRemoteDebugEndpoint(ctx, func(ctx context.Context) (RemoteDebugEndpoint, error) {
|
||||
return i.InspectRemoteDebugEndpoint(ctx, kind, userDataDir)
|
||||
})
|
||||
}
|
||||
|
||||
func (i *CDPInspector) WaitForRemoteDebugPort(ctx context.Context, kind domain.BrowserKind, port int) (RemoteDebugEndpoint, error) {
|
||||
return waitForRemoteDebugEndpoint(ctx, func(ctx context.Context) (RemoteDebugEndpoint, error) {
|
||||
return i.InspectRemoteDebugPort(ctx, kind, port)
|
||||
})
|
||||
}
|
||||
|
||||
func waitForRemoteDebugEndpoint(ctx context.Context, inspect func(context.Context) (RemoteDebugEndpoint, error)) (RemoteDebugEndpoint, error) {
|
||||
const pollInterval = 50 * time.Millisecond
|
||||
for {
|
||||
endpoint, err := inspect(ctx)
|
||||
if err == nil {
|
||||
return endpoint, nil
|
||||
}
|
||||
if !errors.Is(err, ErrRemoteDebugEndpointUnavailable) {
|
||||
return RemoteDebugEndpoint{}, err
|
||||
}
|
||||
timer := time.NewTimer(pollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: %v", ErrRemoteDebugEndpointUnavailable, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *CDPInspector) FindAvailableRemoteDebugPort(ctx context.Context, start int) (int, error) {
|
||||
if i == nil || !domain.ValidRemoteDebugPort(start) {
|
||||
return 0, fmt.Errorf("%w: start port must be between %d and %d", ErrRemoteDebugPortUnavailable, domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort)
|
||||
}
|
||||
for offset := 0; offset < remoteDebugPortAttempts && start+offset <= domain.MaxRemoteDebugPort; offset++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port := start + offset
|
||||
listener, err := net.Listen("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = listener.Close()
|
||||
return port, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%w: no free port in %d candidates starting at %d", ErrRemoteDebugPortUnavailable, remoteDebugPortAttempts, start)
|
||||
}
|
||||
|
||||
func parseDevToolsActivePort(data []byte) (int, error) {
|
||||
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
if len(lines) < 2 {
|
||||
return 0, fmt.Errorf("%w: malformed active port file", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(lines[0]))
|
||||
if err != nil || !domain.ValidRemoteDebugPort(port) {
|
||||
return 0, fmt.Errorf("%w: malformed active port", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if strings.TrimSpace(lines[1]) == "" {
|
||||
return 0, fmt.Errorf("%w: missing browser endpoint", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func browserVersionMatchesKind(browser string, kind domain.BrowserKind) bool {
|
||||
switch kind {
|
||||
case domain.BrowserChrome:
|
||||
return strings.HasPrefix(browser, "Chrome/") || strings.HasPrefix(browser, "HeadlessChrome/")
|
||||
case domain.BrowserEdge:
|
||||
return strings.HasPrefix(browser, "Edg/") || strings.HasPrefix(browser, "HeadlessEdg/")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"chub/internal/domain"
|
||||
)
|
||||
|
||||
func TestCDPInspectorValidatesEndpointFromProfileFile(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/json/version" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"Browser":"Chrome/136.0.0.0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(profile, "DevToolsActivePort"), []byte(portText+"\n/devtools/browser/test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint, err := NewCDPInspector().InspectRemoteDebugEndpoint(context.Background(), domain.BrowserChrome, profile)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectRemoteDebugEndpoint() error = %v", err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portText)
|
||||
if endpoint.Port != port || endpoint.Browser != "Chrome/136.0.0.0" {
|
||||
t.Fatalf("endpoint = %#v", endpoint)
|
||||
}
|
||||
direct, err := NewCDPInspector().InspectRemoteDebugPort(context.Background(), domain.BrowserChrome, port)
|
||||
if err != nil || direct.Port != port {
|
||||
t.Fatalf("InspectRemoteDebugPort() = %#v, %v", direct, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorRejectsMismatchedAndMalformedEndpoints(t *testing.T) {
|
||||
profile := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(profile, "DevToolsActivePort"), []byte("not-a-port\n/devtools/browser/test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewCDPInspector().InspectRemoteDebugEndpoint(context.Background(), domain.BrowserChrome, profile); !errors.Is(err, ErrRemoteDebugEndpointUnavailable) {
|
||||
t.Fatalf("malformed endpoint error = %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"Browser":"Edg/136.0.0.0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(profile, "DevToolsActivePort"), []byte(portText+"\n/devtools/browser/test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewCDPInspector().InspectRemoteDebugEndpoint(context.Background(), domain.BrowserChrome, profile); !errors.Is(err, ErrRemoteDebugEndpointUnavailable) {
|
||||
t.Fatalf("mismatched endpoint error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorFindsNextAvailablePort(t *testing.T) {
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
start := listener.Addr().(*net.TCPAddr).Port
|
||||
port, err := NewCDPInspector().FindAvailableRemoteDebugPort(context.Background(), start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port != start+1 {
|
||||
t.Fatalf("port = %d, want %d", port, start+1)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,12 @@ func BuildArgs(spec domain.LaunchSpec) ([]string, error) {
|
||||
"--no-first-run",
|
||||
"--disable-default-apps",
|
||||
}
|
||||
if normalized.RemoteDebugPort > 0 {
|
||||
args = append(args,
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-debugging-port="+strconv.Itoa(normalized.RemoteDebugPort),
|
||||
)
|
||||
}
|
||||
if normalized.ProfileDirectory != "" {
|
||||
args = append(args, "--profile-directory="+normalized.ProfileDirectory)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,25 @@ func TestBuildArgsAllowsDefaultBrowserPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsAddsLoopbackRemoteDebuggingArguments(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), "profile")
|
||||
args, err := BuildArgs(domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: profile, RemoteDebugPort: 9666})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9666"} {
|
||||
found := false
|
||||
for _, arg := range args {
|
||||
if arg == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("args = %#v, missing %q", args, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovererResolvesConfiguredAndStandardPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
chrome := filepath.Join(root, `Google\Chrome\Application\chrome.exe`)
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestRealChromiumSmoke(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
discoverer := NewDiscoverer()
|
||||
launcher := NewOSLauncher()
|
||||
cdp := NewCDPInspector()
|
||||
type running struct {
|
||||
kind domain.BrowserKind
|
||||
dir string
|
||||
@@ -31,7 +32,24 @@ func TestRealChromiumSmoke(t *testing.T) {
|
||||
t.Fatalf("resolve %s: %v", kind, err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
handle, err := launcher.Start(ctx, domain.LaunchSpec{Kind: kind, Executable: executable, UserDataDir: dir, TargetURL: "https://example.com", Headless: true, ExtraArgs: []string{"--disable-gpu"}})
|
||||
var handle ProcessHandle
|
||||
t.Cleanup(func() {
|
||||
if handle == nil {
|
||||
return
|
||||
}
|
||||
if err := launcher.StopProfile(ctx, dir, true); err != nil && !errors.Is(err, domain.ErrInstanceNotFound) {
|
||||
t.Errorf("cleanup force stop %s: %v", kind, err)
|
||||
return
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, _ = handle.Wait(waitCtx)
|
||||
cancel()
|
||||
})
|
||||
port, err := cdp.FindAvailableRemoteDebugPort(ctx, domain.DefaultRemoteDebugPort)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate %s CDP port: %v", kind, err)
|
||||
}
|
||||
handle, err = launcher.Start(ctx, domain.LaunchSpec{Kind: kind, Executable: executable, UserDataDir: dir, RemoteDebugPort: port, TargetURL: "https://example.com", Headless: true, ExtraArgs: []string{"--disable-gpu"}})
|
||||
if err != nil {
|
||||
t.Fatalf("start %s: %v", kind, err)
|
||||
}
|
||||
@@ -39,6 +57,12 @@ func TestRealChromiumSmoke(t *testing.T) {
|
||||
if handle.PID() <= 0 {
|
||||
t.Fatalf("%s returned invalid PID %d", kind, handle.PID())
|
||||
}
|
||||
endpointCtx, cancelEndpoint := context.WithTimeout(ctx, 10*time.Second)
|
||||
endpoint, endpointErr := cdp.WaitForRemoteDebugPort(endpointCtx, kind, port)
|
||||
cancelEndpoint()
|
||||
if endpointErr != nil || endpoint.Port != port {
|
||||
t.Fatalf("%s CDP endpoint = %+v, %v; want port %d", kind, endpoint, endpointErr, port)
|
||||
}
|
||||
use, err := launcher.InspectProfile(ctx, dir)
|
||||
if err != nil || !use.Occupied || use.PID != handle.PID() {
|
||||
t.Fatalf("%s registry = %+v, %v", kind, use, err)
|
||||
@@ -62,15 +86,4 @@ func TestRealChromiumSmoke(t *testing.T) {
|
||||
if processes[0].handle.PID() == processes[1].handle.PID() {
|
||||
t.Fatalf("Chrome and Edge shared PID %d", processes[0].handle.PID())
|
||||
}
|
||||
for _, item := range processes {
|
||||
if err := launcher.StopProfile(ctx, item.dir, true); err != nil {
|
||||
t.Fatalf("force stop %s: %v", item.kind, err)
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
code, waitErr := item.handle.Wait(waitCtx)
|
||||
cancel()
|
||||
if waitErr != nil {
|
||||
t.Logf("%s force stop returned expected process wait error: code=%d error=%v", item.kind, code, waitErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user