diff --git a/cmd/sin-code/internal/catalog/source_external.go b/cmd/sin-code/internal/catalog/source_external.go index cbc4a154..e46891f3 100644 --- a/cmd/sin-code/internal/catalog/source_external.go +++ b/cmd/sin-code/internal/catalog/source_external.go @@ -74,6 +74,7 @@ type externalServer struct { var externalServers = []externalServer{ {name: "autodev", namespace: "autodev__*", short: "Autodev bridge", description: "Bridged-External autodev MCP server (Python stdio).", example: "autodev__plan --repo owner/repo", tags: []string{"external", "methodology"}}, {name: "browser", namespace: "browser__*", short: "Browser automation", description: "SIN-Browser-Tools MCP server (CDP/headless Chrome).", example: "browser__navigate https://example.com", tags: []string{"external", "browser"}}, + {name: "native_browser", namespace: "native_browser__*", short: "Native browser facade", description: "Pure-Go native_browser facade (issue #382) — net/http + x/net/html HTTP-direct driver for static pages; MCP-delegated driver stubbed for later (no CGO, no Chromium required).", example: "native_browser__navigate https://example.com", tags: []string{"external", "browser", "native"}}, {name: "codocs", namespace: "codocs__*", short: "Doc coauthoring", description: "SIN-Code-Doc-Coauthoring-Skill MCP server.", example: "codocs__draft --section API", tags: []string{"external", "docs"}}, {name: "contextbridge", namespace: "contextbridge__*", short: "Context bridge", description: "SIN-Code-Context-Bridge-Skill MCP server.", example: "contextbridge__query 'auth module'", tags: []string{"external", "context"}}, {name: "frontend", namespace: "frontend__*", short: "Frontend design", description: "SIN-Code-Frontend-Design-Skill MCP server.", example: "frontend__component_create button", tags: []string{"external", "design"}}, diff --git a/cmd/sin-code/internal/mcpclient/registry.go b/cmd/sin-code/internal/mcpclient/registry.go index 7c1de901..238a0ed9 100644 --- a/cmd/sin-code/internal/mcpclient/registry.go +++ b/cmd/sin-code/internal/mcpclient/registry.go @@ -74,6 +74,15 @@ func DefaultServers() []ServerConfig { // v3.22.0: sin-analyse-suite — multimodal preprocessing (image, video, PDF, logs, data, audio) goNative("sin-analyse-suite", "sin-analyse", "serve"), + // v3.22.0 (issue #382): native_browser — pure-Go headless browser facade + // (cmd/sin-code/internal/native_browser). Registered here so its tool + // namespace native_browser__* is enumerated by the catalog + permission + // matrix; the actual implementation runs in-process behind the Driver + // seam and never spawns a subprocess. The optional sin-native-browser + // binary is a future stdio shim — see issue #382 follow-up for the + // release that promotes an MCP façade behind the same namespace. + goNative("native_browser", "sin-native-browser", "serve"), + // External MCP server (Python stdio) — autodev-cli v0.4.0 (Bridged-External, never vendored) {Name: "autodev", Transport: "stdio", Command: "autodev-mcp"}, } @@ -82,7 +91,8 @@ func DefaultServers() []ServerConfig { func shortName(repo string) string { m := map[string]string{ "web_search_bundle": "websearch", - "sin-analyse-suite": "analyse", + "sin-analyse-suite": "analyse", + "native_browser": "native_browser", "SIN-Code-Websearch-Skill": "websearch", "SIN-Code-Scheduler-Skill": "scheduler", "SIN-Code-Goal-Mode-Skill": "goalmode", diff --git a/cmd/sin-code/internal/native_browser/browser.go b/cmd/sin-code/internal/native_browser/browser.go new file mode 100644 index 00000000..5022afd6 --- /dev/null +++ b/cmd/sin-code/internal/native_browser/browser.go @@ -0,0 +1,398 @@ +// SPDX-License-Identifier: MIT +// Purpose: pure-Go native browser-automation facade (issue #382). +// +// The native_browser package exposes a Browser + BrowserSession surface that +// other packages (chat, mcpclient, catalog) can wire up without spawning a +// Python child or pulling in CGO bindings to Chromium. The Driver interface +// fans out to pluggable backends: +// +// - HTTPDirectDriver: net/http + golang.org/x/net/html parser for static +// sites where no JavaScript is required (docs, RFC pages, status pages). +// This is the only fully-implemented driver today. +// - HTTPOnlyDriver: a stub that documents the future MCP-delegated path +// to sin-browser-tools without introducing its own process. It returns +// ErrNotImplemented from every method so callers fail fast and the +// missing-path is visible in error messages, not silently swallowed. +// +// Real headless rendering (Playwright / Chromium) lands later behind the +// same Driver interface — no caller changes required when that lands. +package native_browser + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +// Sentinel errors. Stable surface — callers branch on these. +var ( + ErrClosed = errors.New("native_browser: session closed") + ErrEmptyURL = errors.New("native_browser: empty url") + ErrNoURL = errors.New("native_browser: no current url (call Navigate first)") + ErrUnsupported = errors.New("native_browser: driver does not support this operation") + ErrNotImplemented = errors.New("native_browser: not implemented (issue #382 follow-up)") +) + +// Config tunes a Browser. Driver may be nil (defaults to NewHTTPDirectDriver). +type Config struct { + Driver Driver + UserAgent string + // Timeout caps any single network call. Zero = 30 s default. + Timeout time.Duration + // WaitForPollInterval is how often WaitFor polls the underlying URL. + // Zero = 250 ms default. + WaitForPollInterval time.Duration + // WaitForDeadline caps the total WaitFor duration. Zero = 10 s default. + WaitForDeadline time.Duration +} + +// Browser is the top-level entry point. Holds one shared Driver and spawns +// cheap BrowserSessions on demand. Safe for concurrent use. +type Browser struct { + mu sync.RWMutex + driver Driver + cfg Config + closed bool +} + +// NewBrowser returns a Browser wired against cfg.Driver (or a fresh +// HTTPDirectDriver if nil). +func NewBrowser(cfg Config) *Browser { + if cfg.Driver == nil { + cfg.Driver = NewHTTPDirectDriver() + } + if cfg.Timeout == 0 { + cfg.Timeout = 30 * time.Second + } + if cfg.WaitForPollInterval == 0 { + cfg.WaitForPollInterval = 250 * time.Millisecond + } + if cfg.WaitForDeadline == 0 { + cfg.WaitForDeadline = 10 * time.Second + } + return &Browser{driver: cfg.Driver, cfg: cfg} +} + +// DriverName returns the wrapped Driver's identifier. +func (b *Browser) DriverName() string { + if b == nil { + return "" + } + b.mu.RLock() + defer b.mu.RUnlock() + if b.driver == nil { + return "" + } + return b.driver.Name() +} + +// NewSession opens a new BrowserSession against the shared Driver. +// Sessions are independent — each carries its own URL state. +func (b *Browser) NewSession() (*BrowserSession, error) { + if b == nil { + return nil, errors.New("native_browser: nil browser") + } + b.mu.RLock() + if b.closed { + b.mu.RUnlock() + return nil, ErrClosed + } + driver := b.driver + cfg := b.cfg + b.mu.RUnlock() + return &BrowserSession{driver: driver, cfg: cfg}, nil +} + +// Close shuts down the underlying Driver and refuses new Sessions. Existing +// sessions are also marked closed. +func (b *Browser) Close() error { + if b == nil { + return nil + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return nil + } + b.closed = true + if b.driver != nil { + return b.driver.Close() + } + return nil +} + +// BrowserSession is one logical "tab". Tracks the current URL + cached HTML +// across calls. Calls are serialised through mu — concurrent callers see a +// consistent (URL, HTML) pair. +type BrowserSession struct { + mu sync.Mutex + driver Driver + cfg Config + url string + html string + closed bool +} + +// URL returns the last successful Navigate target. Empty string before the +// first Navigate call. +func (s *BrowserSession) URL() string { + if s == nil { + return "" + } + s.mu.Lock() + defer s.mu.Unlock() + return s.url +} + +// Navigate loads url, stores the page HTML in the session, and resets any +// previously-cached content. On failure the session keeps its previous url +// (so callers can retry without losing context). +func (s *BrowserSession) Navigate(url string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if url == "" { + return ErrEmptyURL + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + driver := s.driver + cfg := s.cfg + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + + page, err := driver.Load(ctx, url) + if err != nil { + return fmt.Errorf("native_browser: navigate %q: %w", url, err) + } + + s.mu.Lock() + s.url = url + s.html = page + s.mu.Unlock() + return nil +} + +// Snapshot returns the HTML string cached by the last successful Navigate. +// Returns ErrNoURL if Navigate has not been called. +func (s *BrowserSession) Snapshot() (string, error) { + if s == nil { + return "", errors.New("native_browser: nil session") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return "", ErrClosed + } + if s.url == "" { + return "", ErrNoURL + } + return s.html, nil +} + +// Click submits a click action against the current URL. Mutating operation +// (M4): the caller is expected to gate this through the permission engine +// (`native_browser__click` policy == "ask"). +func (s *BrowserSession) Click(selector string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if selector == "" { + return errors.New("native_browser: empty selector") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + if err := driver.Perform(ctx, u, ClickAction, selector, ""); err != nil { + return fmt.Errorf("native_browser: click %q: %w", selector, err) + } + return nil +} + +// Fill submits a fill action against the current URL. Mutating operation +// (M4): the caller is expected to gate this through the permission engine. +func (s *BrowserSession) Fill(selector, value string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if selector == "" { + return errors.New("native_browser: empty selector") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + if err := driver.Perform(ctx, u, FillAction, selector, value); err != nil { + return fmt.Errorf("native_browser: fill %q: %w", selector, err) + } + return nil +} + +// Submit posts the form anchored at the current URL. Mutating operation +// (M4): the caller is expected to gate this through the permission engine. +func (s *BrowserSession) Submit(selector string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if selector == "" { + return errors.New("native_browser: empty selector") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + if err := driver.Perform(ctx, u, SubmitAction, selector, ""); err != nil { + return fmt.Errorf("native_browser: submit %q: %w", selector, err) + } + return nil +} + +// Screenshot writes a screenshot of the current URL to path. Drivers that +// do not support rendering return ErrUnsupported. +func (s *BrowserSession) Screenshot(path string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if path == "" { + return errors.New("native_browser: empty path") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + if err := driver.Render(ctx, u, path); err != nil { + return fmt.Errorf("native_browser: screenshot %q: %w", u, err) + } + return nil +} + +// WaitFor polls the current URL until the selector is present in the page +// HTML, or until cfg.WaitForDeadline elapses. +func (s *BrowserSession) WaitFor(selector string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if selector == "" { + return errors.New("native_browser: empty selector") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.WaitForDeadline) + defer cancel() + + ticker := time.NewTicker(cfg.WaitForPollInterval) + defer ticker.Stop() + + // Check once immediately, then on each tick. + for { + page, err := driver.Load(ctx, u) + if err == nil && selectorPresent(page, selector) { + s.mu.Lock() + s.url = u + s.html = page + s.mu.Unlock() + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("native_browser: waitfor %q timed out: %w", selector, ctx.Err()) + case <-ticker.C: + } + } +} + +// Close marks the session closed. Calls after Close return ErrClosed. +func (s *BrowserSession) Close() error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.closed = true + return nil +} + +// --- helpers --------------------------------------------------------------- + +// selectorPresent is a deliberately-loose substring match: real browser +// drivers would parse the DOM and route via CSS / XPath. We just need +// enough for the static-page use cases this driver covers. +func selectorPresent(html, selector string) bool { + if html == "" || selector == "" { + return false + } + sel := strings.TrimSpace(selector) + return strings.Contains(html, sel) +} diff --git a/cmd/sin-code/internal/native_browser/browser_test.go b/cmd/sin-code/internal/native_browser/browser_test.go new file mode 100644 index 00000000..e422d07a --- /dev/null +++ b/cmd/sin-code/internal/native_browser/browser_test.go @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for the native_browser facade (issue #382). Drives the +// Driver seam through StubDriver where possible so the suite stays +// race-clean without a network. The real HTTPDirectDriver exercises +// httptest.NewServer for Navigate + Snapshot; policy split is verified +// against the package's own permission.DefaultPermissionRules surface. +package native_browser + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// --- the tests ------------------------------------------------------------ + +// TestBrowserNavigate verifies the URL can be loaded and that the +// driver is actually invoked end-to-end. +func TestBrowserNavigate(t *testing.T) { + drv := NewStubDriver() + drv.Set("http://example.test/hello", "
hello") + drv.SetDefault("") + + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + + s, err := b.NewSession() + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer s.Close() + + if err := s.Navigate("http://example.test/hello"); err != nil { + t.Fatalf("Navigate: %v", err) + } + if got := s.URL(); got != "http://example.test/hello" { + t.Fatalf("URL after Navigate: got %q", got) + } + + drv.mu.Lock() + calls := append([]string(nil), drv.LoadCalls...) + drv.mu.Unlock() + if len(calls) != 1 || calls[0] != "http://example.test/hello" { + t.Fatalf("expected 1 driver.Load call to the right URL, got %v", calls) + } + + html, err := s.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if !strings.Contains(html, "hello") { + t.Fatalf("Snapshot body should contain page text, got %q", html) + } +} + +// TestBrowserSnapshot covers the Snapshot() contract: returns the +// cached HTML string from the last successful Navigate, no driver calls. +func TestBrowserSnapshot(t *testing.T) { + drv := NewStubDriver() + drv.Set("http://example.test/page", "X
") + + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate("http://example.test/page"); err != nil { + t.Fatalf("Navigate: %v", err) + } + + got, err := s.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if got != "X
" { + t.Fatalf("Snapshot returned unfamiliar body: %q", got) + } + + drv.mu.Lock() + loads := len(drv.LoadCalls) + drv.mu.Unlock() + if loads != 1 { + t.Fatalf("Snapshot should not re-call driver.Load, total calls = %d", loads) + } +} + +// TestBrowserSnapshot_NoURL asserts the documented ErrNoURL contract. +func TestBrowserSnapshot_NoURL(t *testing.T) { + b := NewBrowser(Config{Driver: NewStubDriver()}) + defer b.Close() + + s, _ := b.NewSession() + defer s.Close() + + if _, err := s.Snapshot(); !errors.Is(err, ErrNoURL) { + t.Fatalf("expected ErrNoURL, got %v", err) + } +} + +// TestBrowserNavigate_EmptyURL guards ErrEmptyURL. +func TestBrowserNavigate_EmptyURL(t *testing.T) { + b := NewBrowser(Config{Driver: NewStubDriver()}) + defer b.Close() + + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate(""); !errors.Is(err, ErrEmptyURL) { + t.Fatalf("expected ErrEmptyURL, got %v", err) + } +} + +// TestBrowserSessionLifecycle verifies Close rejects further calls. +func TestBrowserSessionLifecycle(t *testing.T) { + drv := NewStubDriver() + drv.SetDefault("") + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + + s, _ := b.NewSession() + if err := s.Navigate("http://example.test/"); err != nil { + t.Fatalf("Navigate before Close: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := s.Navigate("http://example.test/again"); !errors.Is(err, ErrClosed) { + t.Fatalf("Navigate after Close: expected ErrClosed, got %v", err) + } + if _, err := s.Snapshot(); !errors.Is(err, ErrClosed) { + t.Fatalf("Snapshot after Close: expected ErrClosed, got %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("second Close should be idempotent: %v", err) + } +} + +// TestBrowserClosedRefusesSession verifies a closed Browser refuses +// new sessions. +func TestBrowserClosedRefusesSession(t *testing.T) { + b := NewBrowser(Config{Driver: NewStubDriver()}) + if err := b.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := b.NewSession(); !errors.Is(err, ErrClosed) { + t.Fatalf("NewSession after Close: expected ErrClosed, got %v", err) + } +} + +// TestBrowserNilGuards ensures every public method on a nil receiver +// returns a controlled error instead of crashing. +func TestBrowserNilGuards(t *testing.T) { + var b *Browser + if _, err := b.NewSession(); err == nil { + t.Fatalf("nil Browser NewSession should error, got nil") + } + if err := b.Close(); err != nil { + t.Fatalf("nil Browser Close should be no-op, got %v", err) + } + if got := b.DriverName(); got != "" { + t.Fatalf("nil Browser DriverName should be empty, got %q", got) + } + + var s *BrowserSession + if _, err := s.Snapshot(); err == nil { + t.Fatalf("nil Session Snapshot should error, got nil") + } + if s.URL() != "" { + t.Fatalf("nil Session URL should be empty, got %q", s.URL()) + } + if err := s.Close(); err != nil { + t.Fatalf("nil Session Close should be no-op, got %v", err) + } +} + +// TestDriverStub_PerformRecords verifies Click/Fill/Submit record the +// full (URL, action, selector, value) tuple so the policy layer can +// reason about confirmed-in-place mutations. +func TestDriverStub_PerformRecords(t *testing.T) { + drv := NewStubDriver() + drv.SetDefault("") + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate("http://example.test/"); err != nil { + t.Fatalf("Navigate: %v", err) + } + if err := s.Click("#submit"); err != nil { + t.Fatalf("Click: %v", err) + } + if err := s.Fill("#name", "alice"); err != nil { + t.Fatalf("Fill: %v", err) + } + if err := s.Submit("#login-form"); err != nil { + t.Fatalf("Submit: %v", err) + } + + drv.mu.Lock() + defer drv.mu.Unlock() + if len(drv.PerformCalls) != 3 { + t.Fatalf("expected 3 Perform calls, got %d", len(drv.PerformCalls)) + } + want := []StubPerform{ + {URL: "http://example.test/", Action: ClickAction, Selector: "#submit", Value: ""}, + {URL: "http://example.test/", Action: FillAction, Selector: "#name", Value: "alice"}, + {URL: "http://example.test/", Action: SubmitAction, Selector: "#login-form", Value: ""}, + } + for i, w := range want { + if drv.PerformCalls[i] != w { + t.Errorf("PerformCalls[%d]: expected %+v, got %+v", i, w, drv.PerformCalls[i]) + } + } +} + +// TestDriverStub_ScreenshotNoop verifies the stub records the render +// call without needing a real file write. +func TestDriverStub_ScreenshotNoop(t *testing.T) { + drv := NewStubDriver() + drv.SetDefault("") + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate("http://example.test/"); err != nil { + t.Fatalf("Navigate: %v", err) + } + if err := s.Screenshot("/tmp/sin-native-browser-screenshot.png"); err != nil { + t.Fatalf("Screenshot: %v", err) + } + drv.mu.Lock() + defer drv.mu.Unlock() + if len(drv.RenderCalls) != 1 || drv.RenderCalls[0].Path != "/tmp/sin-native-browser-screenshot.png" { + t.Fatalf("expected 1 Render call with the right path, got %+v", drv.RenderCalls) + } +} + +// TestDriverHTTPOnly_NotImplemented asserts the stub driver surfaces the +// missing-path error clearly (so misconfigured callers cannot hang). +func TestDriverHTTPOnly_NotImplemented(t *testing.T) { + d := NewHTTPOnlyDriver("browser-mcp") + if got := d.Name(); got != "http-only:browser-mcp" { + t.Fatalf("Name: %q", got) + } + if _, err := d.Load(context.Background(), "http://x"); err == nil || !errors.Is(err, ErrNotImplemented) { + t.Fatalf("Load should be ErrNotImplemented, got %v", err) + } + if err := d.Perform(context.Background(), "http://x", ClickAction, "#y", ""); err == nil || !errors.Is(err, ErrNotImplemented) { + t.Fatalf("Perform should be ErrNotImplemented, got %v", err) + } + if err := d.Render(context.Background(), "http://x", "/tmp/x.png"); err == nil || !errors.Is(err, ErrNotImplemented) { + t.Fatalf("Render should be ErrNotImplemented, got %v", err) + } +} + +// TestDriverHTTPDirect_RenderUnsupported verifies the HTTP-direct +// driver reports ErrUnsupported for screenshot rendering — it cannot +// paint pixels without Chromium. +func TestDriverHTTPDirect_RenderUnsupported(t *testing.T) { + d := NewHTTPDirectDriver() + defer d.Close() + if got := d.Name(); got != "http-direct" { + t.Fatalf("Name: got %q", got) + } + err := d.Render(context.Background(), "http://x", "/tmp/x.png") + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("Render should be ErrUnsupported, got %v", err) + } +} + +// TestDriverHTTPDirect_PerformFillUnsupported verifies fill/form mutations +// on the static-page driver fail loudly rather than silently no-op. +func TestDriverHTTPDirect_PerformFillUnsupported(t *testing.T) { + d := NewHTTPDirectDriver() + defer d.Close() + if err := d.Perform(context.Background(), "http://x", FillAction, "#y", "v"); !errors.Is(err, ErrUnsupported) { + t.Fatalf("Fill should bubble ErrUnsupported, got %v", err) + } + if err := d.Perform(context.Background(), "http://x", SubmitAction, "#f", ""); !errors.Is(err, ErrUnsupported) { + t.Fatalf("Submit should bubble ErrUnsupported, got %v", err) + } +} + +// TestBrowserHTTPDirect_NavigateSnapshot wires the real HTTPDirectDriver +// against an httptest.NewServer — the closest thing to a production +// path the package can run without spawning Chromium. +func TestBrowserHTTPDirect_NavigateSnapshot(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`