cli/pkg/webview/webview.go
Vi 4debdc1449 feat: BugSETI app, WebSocket hub, browser automation, and MCP tools (#336)
* feat: add security logging and fix framework regressions

This commit implements comprehensive security event logging and resolves critical regressions in the core framework.

Security Logging:
- Enhanced `pkg/log` with a `Security` level and helper.
- Added `log.Username()` to consistently identify the executing user.
- Instrumented GitHub CLI auth, Agentic configuration, filesystem sandbox, MCP handlers, and MCP TCP transport with security logs.
- Added `SecurityStyle` to the CLI for consistent visual representation of security events.

UniFi Security (CodeQL):
- Refactored `pkg/unifi` to remove hardcoded `InsecureSkipVerify`, resolving a high-severity alert.
- Added a `--verify-tls` flag and configuration option to control TLS verification.
- Updated command handlers to support the new verification parameter.

Framework Fixes:
- Restored original signatures for `MustServiceFor`, `Config()`, and `Display()` in `pkg/framework/core`, which had been corrupted during a merge.
- Fixed `pkg/framework/framework.go` and `pkg/framework/core/runtime_pkg.go` to match the restored signatures.
- These fixes resolve project-wide compilation errors caused by the signature mismatches.

I encountered significant blockers due to a corrupted state of the `dev` branch after a merge, which introduced breaking changes in the core framework's DI system. I had to manually reconcile these signatures with the expected usage across the codebase to restore build stability.

* feat(mcp): add RAG tools (query, ingest, collections)

Add vector database tools to the MCP server for RAG operations:
- rag_query: Search for relevant documentation using semantic similarity
- rag_ingest: Ingest files or directories into the vector database
- rag_collections: List available collections

Uses existing internal/cmd/rag exports (QueryDocs, IngestDirectory, IngestFile)
and pkg/rag for Qdrant client access. Default collection is "hostuk-docs"
with topK=5 for queries.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(mcp): add metrics tools (record, query)

Add MCP tools for recording and querying AI/security metrics events.
The metrics_record tool writes events to daily JSONL files, and the
metrics_query tool provides aggregated statistics by type, repo, and agent.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add 'core mcp serve' command

Add CLI command to start the MCP server for AI tool integration.

- Create internal/cmd/mcpcmd package with serve subcommand
- Support --workspace flag for directory restriction
- Handle SIGINT/SIGTERM for clean shutdown
- Register in full.go build variant

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(ws): add WebSocket hub package for real-time streaming

Add pkg/ws package implementing a hub pattern for WebSocket connections:
- Hub manages client connections, broadcasts, and channel subscriptions
- Client struct represents connected WebSocket clients
- Message types: process_output, process_status, event, error, ping/pong
- Channel-based subscription system (subscribe/unsubscribe)
- SendProcessOutput and SendProcessStatus for process streaming integration
- Full test coverage including concurrency tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(mcp): add process management and WebSocket MCP tools

Add MCP tools for process management:
- process_start: Start a new external process
- process_stop: Gracefully stop a running process
- process_kill: Force kill a process
- process_list: List all managed processes
- process_output: Get captured process output
- process_input: Send input to process stdin

Add MCP tools for WebSocket:
- ws_start: Start WebSocket server for real-time streaming
- ws_info: Get hub statistics (clients, channels)

Update Service struct with optional process.Service and ws.Hub fields,
new WithProcessService and WithWSHub options, getter methods, and
Shutdown method for cleanup.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(webview): add browser automation package via Chrome DevTools Protocol

Add pkg/webview package for browser automation:
- webview.go: Main interface with Connect, Navigate, Click, Type, QuerySelector, Screenshot, Evaluate
- cdp.go: Chrome DevTools Protocol WebSocket client implementation
- actions.go: DOM action types (Click, Type, Hover, Scroll, etc.) and ActionSequence builder
- console.go: Console message capture and filtering with ConsoleWatcher and ExceptionWatcher
- angular.go: Angular-specific helpers for router navigation, component access, and Zone.js stability

Add MCP tools for webview:
- webview_connect/disconnect: Connection management
- webview_navigate: Page navigation
- webview_click/type/query/wait: DOM interaction
- webview_console: Console output capture
- webview_eval: JavaScript execution
- webview_screenshot: Screenshot capture

Add documentation:
- docs/mcp/angular-testing.md: Guide for Angular application testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: document new packages and BugSETI application

- Update CLAUDE.md with documentation for:
  - pkg/ws (WebSocket hub for real-time streaming)
  - pkg/webview (Browser automation via CDP)
  - pkg/mcp (MCP server tools: process, ws, webview)
  - BugSETI application overview
- Add comprehensive README for BugSETI with:
  - Installation and configuration guide
  - Usage workflow documentation
  - Architecture overview
  - Contributing guidelines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bugseti): add BugSETI system tray app with auto-update

BugSETI - Distributed Bug Fixing like SETI@home but for code

Features:
- System tray app with Wails v3
- GitHub issue fetching with label filters
- Issue queue with priority management
- AI context seeding via seed-agent-developer skill
- Automated PR submission flow
- Stats tracking and leaderboard
- Cross-platform notifications
- Self-updating with stable/beta/nightly channels

Includes:
- cmd/bugseti: Main application with Angular frontend
- internal/bugseti: Core services (fetcher, queue, seeder, submit, config, stats, notify)
- internal/bugseti/updater: Auto-update system (checker, downloader, installer)
- .github/workflows/bugseti-release.yml: CI/CD for all platforms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve import cycle and code duplication

- Remove pkg/log import from pkg/io/local to break import cycle
  (pkg/log/rotation.go imports pkg/io, creating circular dependency)
- Use stderr logging for security events in sandbox escape detection
- Remove unused sync/atomic import from core.go
- Fix duplicate LogSecurity function declarations in cli/log.go
- Update workspace/service.go Crypt() call to match interface

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: update tests for new function signatures and format code

- Update core_test.go: Config(), Display() now panic instead of returning error
- Update runtime_pkg_test.go: sr.Config() now panics instead of returning error
- Update MustServiceFor tests to use assert.Panics
- Format BugSETI, MCP tools, and webview packages with gofmt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Snider <631881+Snider@users.noreply.github.com>
Co-authored-by: Claude <developers@lethean.io>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 17:22:05 +00:00

733 lines
18 KiB
Go

// Package webview provides browser automation via Chrome DevTools Protocol (CDP).
//
// The package allows controlling Chrome/Chromium browsers for automated testing,
// web scraping, and GUI automation. Start Chrome with --remote-debugging-port=9222
// to enable the DevTools protocol.
//
// Example usage:
//
// wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
// if err != nil {
// log.Fatal(err)
// }
// defer wv.Close()
//
// if err := wv.Navigate("https://example.com"); err != nil {
// log.Fatal(err)
// }
//
// if err := wv.Click("#submit-button"); err != nil {
// log.Fatal(err)
// }
package webview
import (
"context"
"encoding/base64"
"fmt"
"sync"
"time"
)
// Webview represents a connection to a Chrome DevTools Protocol endpoint.
type Webview struct {
mu sync.RWMutex
client *CDPClient
ctx context.Context
cancel context.CancelFunc
timeout time.Duration
consoleLogs []ConsoleMessage
consoleLimit int
}
// ConsoleMessage represents a captured console log message.
type ConsoleMessage struct {
Type string `json:"type"` // log, warn, error, info, debug
Text string `json:"text"` // Message text
Timestamp time.Time `json:"timestamp"` // When the message was logged
URL string `json:"url"` // Source URL
Line int `json:"line"` // Source line number
Column int `json:"column"` // Source column number
}
// ElementInfo represents information about a DOM element.
type ElementInfo struct {
NodeID int `json:"nodeId"`
TagName string `json:"tagName"`
Attributes map[string]string `json:"attributes"`
InnerHTML string `json:"innerHTML,omitempty"`
InnerText string `json:"innerText,omitempty"`
BoundingBox *BoundingBox `json:"boundingBox,omitempty"`
}
// BoundingBox represents the bounding rectangle of an element.
type BoundingBox struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// Option configures a Webview instance.
type Option func(*Webview) error
// WithDebugURL sets the Chrome DevTools debugging URL.
// Example: http://localhost:9222
func WithDebugURL(url string) Option {
return func(wv *Webview) error {
client, err := NewCDPClient(url)
if err != nil {
return fmt.Errorf("failed to connect to Chrome DevTools: %w", err)
}
wv.client = client
return nil
}
}
// WithTimeout sets the default timeout for operations.
func WithTimeout(d time.Duration) Option {
return func(wv *Webview) error {
wv.timeout = d
return nil
}
}
// WithConsoleLimit sets the maximum number of console messages to retain.
// Default is 1000.
func WithConsoleLimit(limit int) Option {
return func(wv *Webview) error {
wv.consoleLimit = limit
return nil
}
}
// New creates a new Webview instance with the given options.
func New(opts ...Option) (*Webview, error) {
ctx, cancel := context.WithCancel(context.Background())
wv := &Webview{
ctx: ctx,
cancel: cancel,
timeout: 30 * time.Second,
consoleLogs: make([]ConsoleMessage, 0, 100),
consoleLimit: 1000,
}
for _, opt := range opts {
if err := opt(wv); err != nil {
cancel()
return nil, err
}
}
if wv.client == nil {
cancel()
return nil, fmt.Errorf("no debug URL provided; use WithDebugURL option")
}
// Enable console capture
if err := wv.enableConsole(); err != nil {
cancel()
return nil, fmt.Errorf("failed to enable console capture: %w", err)
}
return wv, nil
}
// Close closes the Webview connection.
func (wv *Webview) Close() error {
wv.cancel()
if wv.client != nil {
return wv.client.Close()
}
return nil
}
// Navigate navigates to the specified URL.
func (wv *Webview) Navigate(url string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
_, err := wv.client.Call(ctx, "Page.navigate", map[string]any{
"url": url,
})
if err != nil {
return fmt.Errorf("failed to navigate: %w", err)
}
// Wait for page load
return wv.waitForLoad(ctx)
}
// Click clicks on an element matching the selector.
func (wv *Webview) Click(selector string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
return wv.click(ctx, selector)
}
// Type types text into an element matching the selector.
func (wv *Webview) Type(selector, text string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
return wv.typeText(ctx, selector, text)
}
// QuerySelector finds an element by CSS selector and returns its information.
func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
return wv.querySelector(ctx, selector)
}
// QuerySelectorAll finds all elements matching the selector.
func (wv *Webview) QuerySelectorAll(selector string) ([]*ElementInfo, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
return wv.querySelectorAll(ctx, selector)
}
// GetConsole returns captured console messages.
func (wv *Webview) GetConsole() []ConsoleMessage {
wv.mu.RLock()
defer wv.mu.RUnlock()
result := make([]ConsoleMessage, len(wv.consoleLogs))
copy(result, wv.consoleLogs)
return result
}
// ClearConsole clears captured console messages.
func (wv *Webview) ClearConsole() {
wv.mu.Lock()
defer wv.mu.Unlock()
wv.consoleLogs = wv.consoleLogs[:0]
}
// Screenshot captures a screenshot and returns it as PNG bytes.
func (wv *Webview) Screenshot() ([]byte, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
result, err := wv.client.Call(ctx, "Page.captureScreenshot", map[string]any{
"format": "png",
})
if err != nil {
return nil, fmt.Errorf("failed to capture screenshot: %w", err)
}
dataStr, ok := result["data"].(string)
if !ok {
return nil, fmt.Errorf("invalid screenshot data")
}
data, err := base64.StdEncoding.DecodeString(dataStr)
if err != nil {
return nil, fmt.Errorf("failed to decode screenshot: %w", err)
}
return data, nil
}
// Evaluate executes JavaScript and returns the result.
// Note: This intentionally executes arbitrary JavaScript in the browser context
// for browser automation purposes. The script runs in the sandboxed browser environment.
func (wv *Webview) Evaluate(script string) (any, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
return wv.evaluate(ctx, script)
}
// WaitForSelector waits for an element matching the selector to appear.
func (wv *Webview) WaitForSelector(selector string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
return wv.waitForSelector(ctx, selector)
}
// GetURL returns the current page URL.
func (wv *Webview) GetURL() (string, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
result, err := wv.evaluate(ctx, "window.location.href")
if err != nil {
return "", err
}
url, ok := result.(string)
if !ok {
return "", fmt.Errorf("invalid URL result")
}
return url, nil
}
// GetTitle returns the current page title.
func (wv *Webview) GetTitle() (string, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
result, err := wv.evaluate(ctx, "document.title")
if err != nil {
return "", err
}
title, ok := result.(string)
if !ok {
return "", fmt.Errorf("invalid title result")
}
return title, nil
}
// GetHTML returns the outer HTML of an element or the whole document.
func (wv *Webview) GetHTML(selector string) (string, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
var script string
if selector == "" {
script = "document.documentElement.outerHTML"
} else {
script = fmt.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector)
}
result, err := wv.evaluate(ctx, script)
if err != nil {
return "", err
}
html, ok := result.(string)
if !ok {
return "", fmt.Errorf("invalid HTML result")
}
return html, nil
}
// SetViewport sets the viewport size.
func (wv *Webview) SetViewport(width, height int) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
_, err := wv.client.Call(ctx, "Emulation.setDeviceMetricsOverride", map[string]any{
"width": width,
"height": height,
"deviceScaleFactor": 1,
"mobile": false,
})
return err
}
// SetUserAgent sets the user agent string.
func (wv *Webview) SetUserAgent(userAgent string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
_, err := wv.client.Call(ctx, "Emulation.setUserAgentOverride", map[string]any{
"userAgent": userAgent,
})
return err
}
// Reload reloads the current page.
func (wv *Webview) Reload() error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
_, err := wv.client.Call(ctx, "Page.reload", nil)
if err != nil {
return fmt.Errorf("failed to reload: %w", err)
}
return wv.waitForLoad(ctx)
}
// GoBack navigates back in history.
func (wv *Webview) GoBack() error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
_, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{
"delta": -1,
})
return err
}
// GoForward navigates forward in history.
func (wv *Webview) GoForward() error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
_, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{
"delta": 1,
})
return err
}
// addConsoleMessage adds a console message to the log.
func (wv *Webview) addConsoleMessage(msg ConsoleMessage) {
wv.mu.Lock()
defer wv.mu.Unlock()
if len(wv.consoleLogs) >= wv.consoleLimit {
// Remove oldest messages
wv.consoleLogs = wv.consoleLogs[len(wv.consoleLogs)-wv.consoleLimit+100:]
}
wv.consoleLogs = append(wv.consoleLogs, msg)
}
// enableConsole enables console message capture.
func (wv *Webview) enableConsole() error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel()
// Enable Runtime domain for console events
_, err := wv.client.Call(ctx, "Runtime.enable", nil)
if err != nil {
return err
}
// Enable Page domain for navigation events
_, err = wv.client.Call(ctx, "Page.enable", nil)
if err != nil {
return err
}
// Enable DOM domain
_, err = wv.client.Call(ctx, "DOM.enable", nil)
if err != nil {
return err
}
// Subscribe to console events
wv.client.OnEvent("Runtime.consoleAPICalled", func(params map[string]any) {
wv.handleConsoleEvent(params)
})
return nil
}
// handleConsoleEvent processes console API events.
func (wv *Webview) handleConsoleEvent(params map[string]any) {
msgType, _ := params["type"].(string)
// Extract args
args, _ := params["args"].([]any)
var text string
for i, arg := range args {
if argMap, ok := arg.(map[string]any); ok {
if val, ok := argMap["value"]; ok {
if i > 0 {
text += " "
}
text += fmt.Sprint(val)
}
}
}
// Extract stack trace info
stackTrace, _ := params["stackTrace"].(map[string]any)
var url string
var line, column int
if callFrames, ok := stackTrace["callFrames"].([]any); ok && len(callFrames) > 0 {
if frame, ok := callFrames[0].(map[string]any); ok {
url, _ = frame["url"].(string)
lineFloat, _ := frame["lineNumber"].(float64)
colFloat, _ := frame["columnNumber"].(float64)
line = int(lineFloat)
column = int(colFloat)
}
}
wv.addConsoleMessage(ConsoleMessage{
Type: msgType,
Text: text,
Timestamp: time.Now(),
URL: url,
Line: line,
Column: column,
})
}
// waitForLoad waits for the page to finish loading.
func (wv *Webview) waitForLoad(ctx context.Context) error {
// Use Page.loadEventFired event or poll document.readyState
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
result, err := wv.evaluate(ctx, "document.readyState")
if err != nil {
continue
}
if state, ok := result.(string); ok && state == "complete" {
return nil
}
}
}
}
// waitForSelector waits for an element to appear.
func (wv *Webview) waitForSelector(ctx context.Context, selector string) error {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
script := fmt.Sprintf("!!document.querySelector(%q)", selector)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
result, err := wv.evaluate(ctx, script)
if err != nil {
continue
}
if found, ok := result.(bool); ok && found {
return nil
}
}
}
}
// evaluate evaluates JavaScript in the page context via CDP Runtime.evaluate.
// This is the core method for executing JavaScript in the browser.
func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) {
result, err := wv.client.Call(ctx, "Runtime.evaluate", map[string]any{
"expression": script,
"returnByValue": true,
})
if err != nil {
return nil, fmt.Errorf("failed to evaluate script: %w", err)
}
// Check for exception
if exceptionDetails, ok := result["exceptionDetails"].(map[string]any); ok {
if exception, ok := exceptionDetails["exception"].(map[string]any); ok {
if description, ok := exception["description"].(string); ok {
return nil, fmt.Errorf("JavaScript error: %s", description)
}
}
return nil, fmt.Errorf("JavaScript error")
}
// Extract result value
if resultObj, ok := result["result"].(map[string]any); ok {
return resultObj["value"], nil
}
return nil, nil
}
// querySelector finds an element by selector.
func (wv *Webview) querySelector(ctx context.Context, selector string) (*ElementInfo, error) {
// Get document root
docResult, err := wv.client.Call(ctx, "DOM.getDocument", nil)
if err != nil {
return nil, fmt.Errorf("failed to get document: %w", err)
}
root, ok := docResult["root"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid document root")
}
rootID, ok := root["nodeId"].(float64)
if !ok {
return nil, fmt.Errorf("invalid root node ID")
}
// Query selector
queryResult, err := wv.client.Call(ctx, "DOM.querySelector", map[string]any{
"nodeId": int(rootID),
"selector": selector,
})
if err != nil {
return nil, fmt.Errorf("failed to query selector: %w", err)
}
nodeID, ok := queryResult["nodeId"].(float64)
if !ok || nodeID == 0 {
return nil, fmt.Errorf("element not found: %s", selector)
}
return wv.getElementInfo(ctx, int(nodeID))
}
// querySelectorAll finds all elements matching the selector.
func (wv *Webview) querySelectorAll(ctx context.Context, selector string) ([]*ElementInfo, error) {
// Get document root
docResult, err := wv.client.Call(ctx, "DOM.getDocument", nil)
if err != nil {
return nil, fmt.Errorf("failed to get document: %w", err)
}
root, ok := docResult["root"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid document root")
}
rootID, ok := root["nodeId"].(float64)
if !ok {
return nil, fmt.Errorf("invalid root node ID")
}
// Query selector all
queryResult, err := wv.client.Call(ctx, "DOM.querySelectorAll", map[string]any{
"nodeId": int(rootID),
"selector": selector,
})
if err != nil {
return nil, fmt.Errorf("failed to query selector all: %w", err)
}
nodeIDs, ok := queryResult["nodeIds"].([]any)
if !ok {
return nil, fmt.Errorf("invalid node IDs")
}
elements := make([]*ElementInfo, 0, len(nodeIDs))
for _, id := range nodeIDs {
if nodeID, ok := id.(float64); ok {
if elem, err := wv.getElementInfo(ctx, int(nodeID)); err == nil {
elements = append(elements, elem)
}
}
}
return elements, nil
}
// getElementInfo retrieves information about a DOM node.
func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo, error) {
// Describe node to get attributes
descResult, err := wv.client.Call(ctx, "DOM.describeNode", map[string]any{
"nodeId": nodeID,
})
if err != nil {
return nil, err
}
node, ok := descResult["node"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid node description")
}
tagName, _ := node["nodeName"].(string)
// Parse attributes
attrs := make(map[string]string)
if attrList, ok := node["attributes"].([]any); ok {
for i := 0; i < len(attrList)-1; i += 2 {
key, _ := attrList[i].(string)
val, _ := attrList[i+1].(string)
attrs[key] = val
}
}
// Get bounding box
var box *BoundingBox
if boxResult, err := wv.client.Call(ctx, "DOM.getBoxModel", map[string]any{
"nodeId": nodeID,
}); err == nil {
if model, ok := boxResult["model"].(map[string]any); ok {
if content, ok := model["content"].([]any); ok && len(content) >= 8 {
x, _ := content[0].(float64)
y, _ := content[1].(float64)
x2, _ := content[2].(float64)
y2, _ := content[5].(float64)
box = &BoundingBox{
X: x,
Y: y,
Width: x2 - x,
Height: y2 - y,
}
}
}
}
return &ElementInfo{
NodeID: nodeID,
TagName: tagName,
Attributes: attrs,
BoundingBox: box,
}, nil
}
// click performs a click on an element.
func (wv *Webview) click(ctx context.Context, selector string) error {
// Find element and get its center coordinates
elem, err := wv.querySelector(ctx, selector)
if err != nil {
return err
}
if elem.BoundingBox == nil {
// Fallback to JavaScript click
script := fmt.Sprintf("document.querySelector(%q)?.click()", selector)
_, err := wv.evaluate(ctx, script)
return err
}
// Calculate center point
x := elem.BoundingBox.X + elem.BoundingBox.Width/2
y := elem.BoundingBox.Y + elem.BoundingBox.Height/2
// Dispatch mouse events
for _, eventType := range []string{"mousePressed", "mouseReleased"} {
_, err := wv.client.Call(ctx, "Input.dispatchMouseEvent", map[string]any{
"type": eventType,
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
})
if err != nil {
return fmt.Errorf("failed to dispatch %s: %w", eventType, err)
}
}
return nil
}
// typeText types text into an element.
func (wv *Webview) typeText(ctx context.Context, selector, text string) error {
// Focus the element first
script := fmt.Sprintf("document.querySelector(%q)?.focus()", selector)
_, err := wv.evaluate(ctx, script)
if err != nil {
return fmt.Errorf("failed to focus element: %w", err)
}
// Type each character
for _, char := range text {
_, err := wv.client.Call(ctx, "Input.dispatchKeyEvent", map[string]any{
"type": "keyDown",
"text": string(char),
})
if err != nil {
return fmt.Errorf("failed to dispatch keyDown: %w", err)
}
_, err = wv.client.Call(ctx, "Input.dispatchKeyEvent", map[string]any{
"type": "keyUp",
})
if err != nil {
return fmt.Errorf("failed to dispatch keyUp: %w", err)
}
}
return nil
}