Merge pull request #1028 from krichprollsch/lightpanda

Add Lightpanda as alternative to Chrome for the browser automation tool
This commit is contained in:
Clark Cant authored and GitHub committed 2026-08-01 03:59:31 +07:00
commit 4b865508da
11 files changed
+665 -56

No files matched your search

@@ -0,0 +1,192 @@
//go:build integration
package integration
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/pkg/browser"
)
// These tests require a running Lightpanda CDP sidecar. Set
// LIGHTPANDA_CDP_URL=ws://localhost:9222 (or wherever it lives) before running:
//
// docker run -d --rm -p 9222:9222 lightpanda/browser:latest \
// serve --host 0.0.0.0 --port 9222
// LIGHTPANDA_CDP_URL=ws://localhost:9222 \
// go test -tags integration -run Lightpanda ./tests/integration/
func newLightpandaManager(t *testing.T) *browser.Manager {
t.Helper()
url := mustEnv(t, "LIGHTPANDA_CDP_URL")
m := browser.New(
browser.WithRemoteURL(url),
browser.WithBackend(browser.BackendLightpanda),
browser.WithActionTimeout(15*time.Second),
browser.WithIdleTimeout(0), // disable reaper for predictable tests
browser.WithMaxPages(10),
)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := m.Start(ctx); err != nil {
t.Fatalf("manager start: %v", err)
}
t.Cleanup(func() {
_ = m.Stop(context.Background())
})
return m
}
func mustEnv(t *testing.T, key string) string {
t.Helper()
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
t.Skipf("%s not set; skipping lightpanda integration test", key)
}
return v
}
func TestLightpanda_SingleTenant_Golden(t *testing.T) {
m := newLightpandaManager(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tab, err := m.OpenTab(ctx, "https://example.com")
if err != nil {
t.Fatalf("open tab: %v", err)
}
if tab.TargetID == "" {
t.Fatal("expected non-empty TargetID")
}
if tab.URL != "https://example.com" {
t.Errorf("expected URL https://example.com, got %q", tab.URL)
}
if tab.Title == "" {
t.Errorf("expected non-empty Title (page.Info() should populate from initial post-open call)")
}
tabs, err := m.ListTabs(ctx)
if err != nil {
t.Fatalf("list tabs: %v", err)
}
if len(tabs) != 1 {
t.Fatalf("expected 1 tab, got %d", len(tabs))
}
if tabs[0].Title != tab.Title {
t.Errorf("ListTabs should return cached title %q, got %q", tab.Title, tabs[0].Title)
}
if err := m.CloseTab(ctx, tab.TargetID); err != nil {
t.Fatalf("close tab: %v", err)
}
tabs, _ = m.ListTabs(ctx)
if len(tabs) != 0 {
t.Fatalf("expected 0 tabs after close, got %d", len(tabs))
}
}
// TestLightpanda_Snapshot_AndEval covers the agent's primary "see the page"
// workflow: AX-tree snapshot for structure + Eval (function form) for JS
// access. AX-tree decoding required Lightpanda upstream fix
// lightpanda-io/browser#2232.
func TestLightpanda_Snapshot_AndEval(t *testing.T) {
m := newLightpandaManager(t)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
tab, err := m.OpenTab(ctx, "https://example.com")
if err != nil {
t.Fatalf("open tab: %v", err)
}
snap, err := m.Snapshot(ctx, tab.TargetID, browser.DefaultSnapshotOptions())
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if snap.Snapshot == "" {
t.Error("expected non-empty AX snapshot text")
}
if snap.Stats.Refs == 0 {
t.Error("expected at least one ref in snapshot")
}
v, err := m.Evaluate(ctx, tab.TargetID, "() => document.title")
if err != nil {
t.Errorf("Evaluate(function form): %v", err)
}
if v == "" {
t.Errorf("Evaluate(() => document.title) returned empty value")
}
}
func TestLightpanda_MultiTenant_Isolation(t *testing.T) {
m := newLightpandaManager(t)
tenantA := "11111111-1111-1111-1111-111111111111"
tenantB := "22222222-2222-2222-2222-222222222222"
base := context.Background()
ctxA := browser.WithTenantID(base, tenantA)
ctxB := browser.WithTenantID(base, tenantB)
tabA, err := m.OpenTab(ctxA, "https://example.com")
if err != nil {
t.Fatalf("tenant A open: %v", err)
}
tabB, err := m.OpenTab(ctxB, "https://example.com")
if err != nil {
t.Fatalf("tenant B open: %v", err)
}
listA, _ := m.ListTabs(ctxA)
if len(listA) != 1 || listA[0].TargetID != tabA.TargetID {
t.Errorf("tenant A should see only its own tab; got %v", listA)
}
listB, _ := m.ListTabs(ctxB)
if len(listB) != 1 || listB[0].TargetID != tabB.TargetID {
t.Errorf("tenant B should see only its own tab; got %v", listB)
}
// Tenant A must not be able to close tenant B's tab.
if err := m.CloseTab(ctxA, tabB.TargetID); err == nil {
t.Error("expected error when tenant A closes tenant B's tab; got nil")
}
}
func TestLightpanda_Backend_ReportedCorrectly(t *testing.T) {
m := newLightpandaManager(t)
if got := m.Backend(); got != browser.BackendLightpanda {
t.Errorf("expected backend %q, got %q", browser.BackendLightpanda, got)
}
}
func TestLightpanda_Screenshot_BlockedByToolGuard(t *testing.T) {
m := newLightpandaManager(t)
tool := browser.NewBrowserTool(m)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
tab, err := m.OpenTab(ctx, "https://example.com")
if err != nil {
t.Fatalf("open tab: %v", err)
}
res := tool.Execute(ctx, map[string]any{
"action": "screenshot",
"targetId": tab.TargetID,
})
if res == nil || !res.IsError {
t.Fatal("expected screenshot to return an error result on lightpanda")
}
msg := strings.ToLower(res.ForLLM)
if !strings.Contains(msg, "lightpanda") || !strings.Contains(msg, "snapshot") {
t.Errorf("expected error to mention lightpanda + snapshot; got %q", res.ForLLM)
}
}