package daemon import ( "context" "encoding/json" "fmt" "io" "log" "math" "net" "net/http" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" ) const Version = "2.0.0" // Daemon is the main server that manages browser connections type Daemon struct { browser *rod.Browser tabs map[string]*rod.Page iframePages map[string]*rod.Page // Maps tab ID to iframe page context currentTab string // ID of the current/last used tab tabHistory []string // Stack of tab IDs in order of activation (LIFO) consoleLogs map[string][]ConsoleLog // Maps tab ID to console logs debug bool // Enable debug logging mu sync.Mutex server *http.Server } // ConsoleLog represents a console log entry type ConsoleLog struct { Level string `json:"level"` // log, warn, error, info, debug Message string `json:"message"` // The log message Timestamp time.Time `json:"timestamp"` // When the log occurred Source string `json:"source"` // Source location if available } // Command represents a command sent from the client to the daemon type Command struct { Action string `json:"action"` Params map[string]string `json:"params"` } // Response represents a response from the daemon to the client type Response struct { Success bool `json:"success"` Data interface{} `json:"data,omitempty"` Error string `json:"error,omitempty"` } // checkChromeRunning checks if Chrome is running on the debug port func checkChromeRunning(port int) bool { conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), 2*time.Second) if err != nil { return false } conn.Close() return true } // checkChromeDevTools checks if Chrome DevTools protocol is responding func checkChromeDevTools(port int) bool { resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/version", port)) if err != nil { return false } defer resp.Body.Close() return resp.StatusCode == 200 } // debugLog logs a message only if debug mode is enabled func (d *Daemon) debugLog(format string, args ...interface{}) { if d.debug { log.Printf("[DEBUG] "+format, args...) } } // NewDaemon creates a new daemon instance func NewDaemon(host string, port int, debug bool) (*Daemon, error) { if debug { log.Printf("[DEBUG] Creating new daemon on %s:%d", host, port) } // Check if Chrome is running on the debug port chromePort := 9222 // Default Chrome debug port if debug { log.Printf("[DEBUG] Checking if Chrome is running on port %d", chromePort) } if !checkChromeRunning(chromePort) { return nil, fmt.Errorf("Chromium is not running with remote debugging enabled on port %d.\n\nTo start Chromium with remote debugging:\n chromium --remote-debugging-port=%d --user-data-dir=/tmp/chromium-debug &\n # or\n google-chrome --remote-debugging-port=%d --user-data-dir=/tmp/chrome-debug &\n\nNote: The --user-data-dir flag is required to avoid conflicts with existing browser instances.", chromePort, chromePort, chromePort) } // Check if Chromium DevTools protocol is responding if !checkChromeDevTools(chromePort) { return nil, fmt.Errorf("Something is listening on port %d but it's not Chromium DevTools protocol.\n\nThis might be:\n1. Chromium running without --remote-debugging-port=%d\n2. Another application using port %d\n\nTry stopping the process on port %d and starting Chromium with:\n chromium --remote-debugging-port=%d --user-data-dir=/tmp/chromium-debug &", chromePort, chromePort, chromePort, chromePort, chromePort) } // Connect to the existing browser instance u := launcher.MustResolveURL("") browser := rod.New().ControlURL(u) err := browser.Connect() if err != nil { return nil, fmt.Errorf("Chromium DevTools is responding on port %d but rod connection failed: %w\n\nThis is unexpected. Try restarting Chromium with:\n chromium --remote-debugging-port=%d --user-data-dir=/tmp/chromium-debug &", chromePort, err, chromePort) } if debug { log.Printf("[DEBUG] Successfully connected to browser via rod") } daemon := &Daemon{ browser: browser, tabs: make(map[string]*rod.Page), iframePages: make(map[string]*rod.Page), tabHistory: make([]string, 0), consoleLogs: make(map[string][]ConsoleLog), debug: debug, } daemon.debugLog("Daemon struct initialized") // Create HTTP server daemon.debugLog("Setting up HTTP server") mux := http.NewServeMux() mux.HandleFunc("/command", daemon.handleCommand) mux.HandleFunc("/status", daemon.handleStatus) mux.HandleFunc("/upload", daemon.handleFileUpload) mux.HandleFunc("/download", daemon.handleFileDownload) daemon.server = &http.Server{ Addr: fmt.Sprintf("%s:%d", host, port), Handler: mux, } daemon.debugLog("HTTP server configured on %s:%d", host, port) return daemon, nil } // Start starts the daemon server func (d *Daemon) Start() error { log.Printf("Starting daemon server on %s", d.server.Addr) d.debugLog("About to call ListenAndServe()") err := d.server.ListenAndServe() d.debugLog("ListenAndServe() returned with error: %v", err) return err } // Stop stops the daemon server func (d *Daemon) Stop() error { log.Println("Stopping daemon server") return d.server.Close() } // handleStatus handles status requests func (d *Daemon) handleStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } d.mu.Lock() tabCount := len(d.tabs) tabs := make(map[string]string) currentTab := d.currentTab tabHistory := make([]string, len(d.tabHistory)) copy(tabHistory, d.tabHistory) // Get info about each tab for id, page := range d.tabs { try := func() string { info, err := page.Info() if err != nil { return "" } return info.URL } tabs[id] = try() } d.mu.Unlock() response := Response{ Success: true, Data: map[string]interface{}{ "status": "running", "tab_count": tabCount, "tabs": tabs, "current_tab": currentTab, "tab_history": tabHistory, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // handleCommand handles command requests func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) { d.debugLog("Received HTTP request: %s %s", r.Method, r.URL.Path) if r.Method != http.MethodPost { d.debugLog("Invalid method: %s", r.Method) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var cmd Command err := json.NewDecoder(r.Body).Decode(&cmd) if err != nil { d.debugLog("Failed to decode JSON: %v", err) http.Error(w, "Invalid request body", http.StatusBadRequest) return } d.debugLog("Processing command: %s with params: %+v", cmd.Action, cmd.Params) var response Response switch cmd.Action { case "version": response = Response{ Success: true, Data: Version, } case "open-tab": timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } tabID, err := d.openTab(timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: tabID} } case "load-url": tabID := cmd.Params["tab"] url := cmd.Params["url"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.loadURL(tabID, url, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "fill-form": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] value := cmd.Params["value"] // Parse timeouts selectionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { selectionTimeout = parsedTimeout } } actionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["action-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { actionTimeout = parsedTimeout } } err := d.fillFormField(tabID, selector, value, selectionTimeout, actionTimeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "upload-file": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] filePath := cmd.Params["file"] // Parse timeouts selectionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { selectionTimeout = parsedTimeout } } actionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["action-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { actionTimeout = parsedTimeout } } err := d.uploadFile(tabID, selector, filePath, selectionTimeout, actionTimeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "submit-form": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] // Parse timeouts selectionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { selectionTimeout = parsedTimeout } } actionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["action-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { actionTimeout = parsedTimeout } } err := d.submitForm(tabID, selector, selectionTimeout, actionTimeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "get-source": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } source, err := d.getPageSource(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: source} } case "get-element": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] // Parse timeouts selectionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { selectionTimeout = parsedTimeout } } html, err := d.getElementHTML(tabID, selector, selectionTimeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: html} } case "close-tab": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.closeTab(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "wait-navigation": tabID := cmd.Params["tab"] timeout := 5 // Default timeout if timeoutStr, ok := cmd.Params["timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.waitNavigation(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "click-element": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] // Parse timeouts selectionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { selectionTimeout = parsedTimeout } } actionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["action-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { actionTimeout = parsedTimeout } } err := d.clickElement(tabID, selector, selectionTimeout, actionTimeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "select-element": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] value := cmd.Params["value"] // Parse timeouts selectionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { selectionTimeout = parsedTimeout } } actionTimeout := 5 // Default: 5 seconds if timeoutStr, ok := cmd.Params["action-timeout"]; ok { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { actionTimeout = parsedTimeout } } err := d.selectElement(tabID, selector, value, selectionTimeout, actionTimeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "eval-js": tabID := cmd.Params["tab"] jsCode := cmd.Params["code"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.evalJS(tabID, jsCode, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "switch-iframe": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.switchToIframe(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "switch-main": tabID := cmd.Params["tab"] err := d.switchToMain(tabID) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "screenshot": tabID := cmd.Params["tab"] outputPath := cmd.Params["output"] fullPageStr := cmd.Params["full-page"] zoomLevelStr := cmd.Params["zoom_level"] // Optional: zoom level (e.g., "2.0") viewportWidthStr := cmd.Params["width"] // Optional: viewport width viewportHeightStr := cmd.Params["height"] // Optional: viewport height timeoutStr := cmd.Params["timeout"] // Parse full-page flag fullPage := false if fullPageStr == "true" { fullPage = true } // Parse zoom level var zoomLevel float64 if zoomLevelStr != "" { if parsed, err := strconv.ParseFloat(zoomLevelStr, 64); err == nil && parsed > 0 { zoomLevel = parsed } } // Parse viewport dimensions var viewportWidth, viewportHeight int if viewportWidthStr != "" { if parsed, err := strconv.Atoi(viewportWidthStr); err == nil && parsed > 0 { viewportWidth = parsed } } if viewportHeightStr != "" { if parsed, err := strconv.Atoi(viewportHeightStr); err == nil && parsed > 0 { viewportHeight = parsed } } // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.takeScreenshotEnhanced(tabID, outputPath, fullPage, zoomLevel, viewportWidth, viewportHeight, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "console-logs": tabID := cmd.Params["tab"] clearStr := cmd.Params["clear"] // Parse clear flag clear := false if clearStr == "true" { clear = true } logs, err := d.getConsoleLogs(tabID, clear) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: logs} } case "console-command": tabID := cmd.Params["tab"] command := cmd.Params["command"] injectLibrary := cmd.Params["inject_library"] // Optional: library URL or name timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } // Inject library if specified if injectLibrary != "" { err := d.injectLibrary(tabID, injectLibrary, timeout) if err != nil { response = Response{Success: false, Error: fmt.Sprintf("failed to inject library: %v", err)} break } } result, err := d.executeConsoleCommand(tabID, command, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "check-element": d.debugLog("Processing check-element command") tabID := cmd.Params["tab"] selector := cmd.Params["selector"] checkType := cmd.Params["type"] // exists, visible, enabled, focused, selected timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.checkElement(tabID, selector, checkType, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "get-element-attributes": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] attributes := cmd.Params["attributes"] // comma-separated list or "all" timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.getElementAttributes(tabID, selector, attributes, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "count-elements": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } count, err := d.countElements(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: count} } case "extract-multiple": tabID := cmd.Params["tab"] selectors := cmd.Params["selectors"] // JSON array of selectors timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.extractMultiple(tabID, selectors, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "extract-links": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] // Optional: filter links by container selector hrefPattern := cmd.Params["href-pattern"] // Optional: regex pattern for href textPattern := cmd.Params["text-pattern"] // Optional: regex pattern for link text timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.extractLinks(tabID, selector, hrefPattern, textPattern, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "extract-table": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] includeHeaders := cmd.Params["include-headers"] == "true" timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.extractTable(tabID, selector, includeHeaders, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "extract-text": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] pattern := cmd.Params["pattern"] // Optional: regex pattern to match within text extractType := cmd.Params["type"] // text, innerText, textContent (default: textContent) timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.extractText(tabID, selector, pattern, extractType, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "analyze-form": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.analyzeForm(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "interact-multiple": tabID := cmd.Params["tab"] interactionsJSON := cmd.Params["interactions"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.interactMultiple(tabID, interactionsJSON, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "fill-form-bulk": tabID := cmd.Params["tab"] formSelector := cmd.Params["form-selector"] fieldsJSON := cmd.Params["fields"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.fillFormBulk(tabID, formSelector, fieldsJSON, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "get-page-info": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.getPageInfo(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "get-viewport-info": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.getViewportInfo(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "get-performance": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.getPerformance(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "check-content": tabID := cmd.Params["tab"] contentType := cmd.Params["type"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.checkContent(tabID, contentType, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "screenshot-element": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] outputPath := cmd.Params["output"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.screenshotElement(tabID, selector, outputPath, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "screenshot-enhanced": tabID := cmd.Params["tab"] outputPath := cmd.Params["output"] fullPageStr := cmd.Params["full-page"] timeoutStr := cmd.Params["timeout"] // Parse full-page flag fullPage := false if fullPageStr == "true" { fullPage = true } // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.screenshotEnhanced(tabID, outputPath, fullPage, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "bulk-files": operationType := cmd.Params["operation"] // "upload" or "download" filesJSON := cmd.Params["files"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 30 seconds for bulk operations) timeout := 30 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.bulkFiles(operationType, filesJSON, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "manage-files": operation := cmd.Params["operation"] // "cleanup", "list", "info" pattern := cmd.Params["pattern"] // file pattern for cleanup/list maxAge := cmd.Params["max-age"] // max age in hours for cleanup result, err := d.manageFiles(operation, pattern, maxAge) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } // Accessibility tree commands case "get-accessibility-tree": tabID := cmd.Params["tab"] depth := cmd.Params["depth"] includeContrastStr := cmd.Params["include_contrast"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } // Parse depth (optional) var depthInt *int if depth != "" { if parsedDepth, err := strconv.Atoi(depth); err == nil && parsedDepth >= 0 { depthInt = &parsedDepth } } // Parse include_contrast flag includeContrast := false if includeContrastStr == "true" { includeContrast = true } result, err := d.getAccessibilityTreeWithContrast(tabID, depthInt, includeContrast, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "get-partial-accessibility-tree": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] fetchRelatives := cmd.Params["fetch-relatives"] // "true" or "false" timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } // Parse fetchRelatives (default to true) fetchRel := true if fetchRelatives == "false" { fetchRel = false } result, err := d.getPartialAccessibilityTree(tabID, selector, fetchRel, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "query-accessibility-tree": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] accessibleName := cmd.Params["accessible-name"] role := cmd.Params["role"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.queryAccessibilityTree(tabID, selector, accessibleName, role, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "disable-cache": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.setCacheDisabled(tabID, true, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "enable-cache": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.setCacheDisabled(tabID, false, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "clear-cache": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.clearBrowserCache(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "clear-all-site-data": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.clearAllSiteData(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "clear-cookies": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.clearCookies(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "clear-storage": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 5 seconds if not specified) timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.clearStorage(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "drag-and-drop": tabID := cmd.Params["tab"] sourceSelector := cmd.Params["source"] targetSelector := cmd.Params["target"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if sourceSelector == "" { response = Response{Success: false, Error: "source selector is required"} break } if targetSelector == "" { response = Response{Success: false, Error: "target selector is required"} break } err := d.dragAndDrop(tabID, sourceSelector, targetSelector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "drag-and-drop-coordinates": tabID := cmd.Params["tab"] sourceSelector := cmd.Params["source"] targetXStr := cmd.Params["target-x"] targetYStr := cmd.Params["target-y"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if sourceSelector == "" { response = Response{Success: false, Error: "source selector is required"} break } if targetXStr == "" || targetYStr == "" { response = Response{Success: false, Error: "target-x and target-y coordinates are required"} break } targetX, err := strconv.Atoi(targetXStr) if err != nil { response = Response{Success: false, Error: "invalid target-x coordinate"} break } targetY, err := strconv.Atoi(targetYStr) if err != nil { response = Response{Success: false, Error: "invalid target-y coordinate"} break } err = d.dragAndDropToCoordinates(tabID, sourceSelector, targetX, targetY, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "drag-and-drop-offset": tabID := cmd.Params["tab"] sourceSelector := cmd.Params["source"] offsetXStr := cmd.Params["offset-x"] offsetYStr := cmd.Params["offset-y"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if sourceSelector == "" { response = Response{Success: false, Error: "source selector is required"} break } if offsetXStr == "" || offsetYStr == "" { response = Response{Success: false, Error: "offset-x and offset-y are required"} break } offsetX, err := strconv.Atoi(offsetXStr) if err != nil { response = Response{Success: false, Error: "invalid offset-x value"} break } offsetY, err := strconv.Atoi(offsetYStr) if err != nil { response = Response{Success: false, Error: "invalid offset-y value"} break } err = d.dragAndDropByOffset(tabID, sourceSelector, offsetX, offsetY, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "right-click": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if selector == "" { response = Response{Success: false, Error: "selector is required"} break } err := d.rightClick(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "double-click": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if selector == "" { response = Response{Success: false, Error: "selector is required"} break } err := d.doubleClick(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "middle-click": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if selector == "" { response = Response{Success: false, Error: "selector is required"} break } err := d.middleClick(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "hover": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if selector == "" { response = Response{Success: false, Error: "selector is required"} break } err := d.hover(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "mouse-move": tabID := cmd.Params["tab"] xStr := cmd.Params["x"] yStr := cmd.Params["y"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if xStr == "" || yStr == "" { response = Response{Success: false, Error: "x and y coordinates are required"} break } x, err := strconv.Atoi(xStr) if err != nil { response = Response{Success: false, Error: "invalid x coordinate"} break } y, err := strconv.Atoi(yStr) if err != nil { response = Response{Success: false, Error: "invalid y coordinate"} break } err = d.mouseMove(tabID, x, y, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "scroll-wheel": tabID := cmd.Params["tab"] xStr := cmd.Params["x"] yStr := cmd.Params["y"] deltaXStr := cmd.Params["delta-x"] deltaYStr := cmd.Params["delta-y"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if xStr == "" || yStr == "" || deltaXStr == "" || deltaYStr == "" { response = Response{Success: false, Error: "x, y, delta-x, and delta-y are required"} break } x, err := strconv.Atoi(xStr) if err != nil { response = Response{Success: false, Error: "invalid x coordinate"} break } y, err := strconv.Atoi(yStr) if err != nil { response = Response{Success: false, Error: "invalid y coordinate"} break } deltaX, err := strconv.Atoi(deltaXStr) if err != nil { response = Response{Success: false, Error: "invalid delta-x value"} break } deltaY, err := strconv.Atoi(deltaYStr) if err != nil { response = Response{Success: false, Error: "invalid delta-y value"} break } err = d.scrollWheel(tabID, x, y, deltaX, deltaY, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "key-combination": tabID := cmd.Params["tab"] keys := cmd.Params["keys"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if keys == "" { response = Response{Success: false, Error: "keys parameter is required"} break } err := d.keyCombination(tabID, keys, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "special-key": tabID := cmd.Params["tab"] key := cmd.Params["key"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if key == "" { response = Response{Success: false, Error: "key parameter is required"} break } err := d.specialKey(tabID, key, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "modifier-click": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] modifiers := cmd.Params["modifiers"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if selector == "" { response = Response{Success: false, Error: "selector is required"} break } if modifiers == "" { response = Response{Success: false, Error: "modifiers parameter is required"} break } err := d.modifierClick(tabID, selector, modifiers, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "touch-tap": tabID := cmd.Params["tab"] xStr := cmd.Params["x"] yStr := cmd.Params["y"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if xStr == "" || yStr == "" { response = Response{Success: false, Error: "x and y coordinates are required"} break } x, err := strconv.Atoi(xStr) if err != nil { response = Response{Success: false, Error: "invalid x coordinate"} break } y, err := strconv.Atoi(yStr) if err != nil { response = Response{Success: false, Error: "invalid y coordinate"} break } err = d.touchTap(tabID, x, y, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "touch-long-press": tabID := cmd.Params["tab"] xStr := cmd.Params["x"] yStr := cmd.Params["y"] durationStr := cmd.Params["duration"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } duration := 1000 // Default 1000ms if durationStr != "" { if parsedDuration, err := strconv.Atoi(durationStr); err == nil && parsedDuration > 0 { duration = parsedDuration } } if xStr == "" || yStr == "" { response = Response{Success: false, Error: "x and y coordinates are required"} break } x, err := strconv.Atoi(xStr) if err != nil { response = Response{Success: false, Error: "invalid x coordinate"} break } y, err := strconv.Atoi(yStr) if err != nil { response = Response{Success: false, Error: "invalid y coordinate"} break } err = d.touchLongPress(tabID, x, y, duration, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "touch-swipe": tabID := cmd.Params["tab"] startXStr := cmd.Params["start-x"] startYStr := cmd.Params["start-y"] endXStr := cmd.Params["end-x"] endYStr := cmd.Params["end-y"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if startXStr == "" || startYStr == "" || endXStr == "" || endYStr == "" { response = Response{Success: false, Error: "start-x, start-y, end-x, and end-y are required"} break } startX, err := strconv.Atoi(startXStr) if err != nil { response = Response{Success: false, Error: "invalid start-x coordinate"} break } startY, err := strconv.Atoi(startYStr) if err != nil { response = Response{Success: false, Error: "invalid start-y coordinate"} break } endX, err := strconv.Atoi(endXStr) if err != nil { response = Response{Success: false, Error: "invalid end-x coordinate"} break } endY, err := strconv.Atoi(endYStr) if err != nil { response = Response{Success: false, Error: "invalid end-y coordinate"} break } err = d.touchSwipe(tabID, startX, startY, endX, endY, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "pinch-zoom": tabID := cmd.Params["tab"] centerXStr := cmd.Params["center-x"] centerYStr := cmd.Params["center-y"] scaleStr := cmd.Params["scale"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if centerXStr == "" || centerYStr == "" || scaleStr == "" { response = Response{Success: false, Error: "center-x, center-y, and scale are required"} break } centerX, err := strconv.Atoi(centerXStr) if err != nil { response = Response{Success: false, Error: "invalid center-x coordinate"} break } centerY, err := strconv.Atoi(centerYStr) if err != nil { response = Response{Success: false, Error: "invalid center-y coordinate"} break } scale, err := strconv.ParseFloat(scaleStr, 64) if err != nil { response = Response{Success: false, Error: "invalid scale value"} break } err = d.pinchZoom(tabID, centerX, centerY, scale, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "scroll-element": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] deltaXStr := cmd.Params["delta-x"] deltaYStr := cmd.Params["delta-y"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if selector == "" || deltaXStr == "" || deltaYStr == "" { response = Response{Success: false, Error: "selector, delta-x, and delta-y are required"} break } deltaX, err := strconv.Atoi(deltaXStr) if err != nil { response = Response{Success: false, Error: "invalid delta-x value"} break } deltaY, err := strconv.Atoi(deltaYStr) if err != nil { response = Response{Success: false, Error: "invalid delta-y value"} break } err = d.scrollElement(tabID, selector, deltaX, deltaY, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "scroll-to-coordinates": tabID := cmd.Params["tab"] xStr := cmd.Params["x"] yStr := cmd.Params["y"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if xStr == "" || yStr == "" { response = Response{Success: false, Error: "x and y coordinates are required"} break } x, err := strconv.Atoi(xStr) if err != nil { response = Response{Success: false, Error: "invalid x coordinate"} break } y, err := strconv.Atoi(yStr) if err != nil { response = Response{Success: false, Error: "invalid y coordinate"} break } err = d.scrollToCoordinates(tabID, x, y, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "select-text": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] startStr := cmd.Params["start"] endStr := cmd.Params["end"] timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } if selector == "" || startStr == "" || endStr == "" { response = Response{Success: false, Error: "selector, start, and end are required"} break } start, err := strconv.Atoi(startStr) if err != nil { response = Response{Success: false, Error: "invalid start index"} break } end, err := strconv.Atoi(endStr) if err != nil { response = Response{Success: false, Error: "invalid end index"} break } err = d.selectText(tabID, selector, start, end, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "select-all-text": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] // Optional - if empty, selects all text on page timeoutStr := cmd.Params["timeout"] timeout := 5 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.selectAllText(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true} } case "inject-axe": tabID := cmd.Params["tab"] axeVersion := cmd.Params["version"] // Optional: specific axe-core version timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds for library injection) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } err := d.injectAxeCore(tabID, axeVersion, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: "axe-core injected successfully"} } case "run-axe": tabID := cmd.Params["tab"] optionsJSON := cmd.Params["options"] // Optional: JSON string with axe.run() options timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 30 seconds for comprehensive testing) timeout := 30 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } // Parse options if provided var options map[string]interface{} if optionsJSON != "" { err := json.Unmarshal([]byte(optionsJSON), &options) if err != nil { response = Response{Success: false, Error: fmt.Sprintf("invalid options JSON: %v", err)} break } } result, err := d.runAxeCore(tabID, options, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "check-contrast": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] // Optional: CSS selector for specific elements timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.checkContrast(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "check-gradient-contrast": tabID := cmd.Params["tab"] selector := cmd.Params["selector"] // Required: CSS selector for element with gradient background timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } // Selector is required for gradient contrast check if selector == "" { response = Response{Success: false, Error: "selector parameter is required for gradient contrast check"} } else { result, err := d.checkGradientContrast(tabID, selector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } } case "validate-media": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.validateMedia(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "test-hover-focus": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.testHoverFocusContent(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "detect-text-in-images": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 30 seconds for OCR processing) timeout := 30 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.detectTextInImages(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "check-cross-page-consistency": tabID := cmd.Params["tab"] urlsStr := cmd.Params["urls"] timeoutStr := cmd.Params["timeout"] // Parse URLs (comma-separated) if urlsStr == "" { response = Response{Success: false, Error: "urls parameter is required"} break } urls := strings.Split(urlsStr, ",") for i := range urls { urls[i] = strings.TrimSpace(urls[i]) } // Parse timeout (default to 10 seconds per page) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.checkCrossPageConsistency(tabID, urls, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "detect-sensory-characteristics": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.detectSensoryCharacteristics(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "detect-animation-flash": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.detectAnimationFlash(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "analyze-enhanced-accessibility": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.analyzeEnhancedAccessibility(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "test-keyboard": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 15 seconds for comprehensive testing) timeout := 15 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } result, err := d.testKeyboardNavigation(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "test-zoom": tabID := cmd.Params["tab"] zoomLevelsStr := cmd.Params["zoom_levels"] // Optional: comma-separated zoom levels timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds per zoom level) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } // Parse zoom levels if provided var zoomLevels []float64 if zoomLevelsStr != "" { levels := strings.Split(zoomLevelsStr, ",") for _, level := range levels { if zoom, err := strconv.ParseFloat(strings.TrimSpace(level), 64); err == nil && zoom > 0 { zoomLevels = append(zoomLevels, zoom) } } } result, err := d.testZoom(tabID, zoomLevels, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "test-reflow": tabID := cmd.Params["tab"] widthsStr := cmd.Params["widths"] // Optional: comma-separated widths timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds per width) timeout := 10 if timeoutStr != "" { if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { timeout = parsedTimeout } } // Parse widths if provided var widths []int if widthsStr != "" { widthStrs := strings.Split(widthsStr, ",") for _, widthStr := range widthStrs { if width, err := strconv.Atoi(strings.TrimSpace(widthStr)); err == nil && width > 0 { widths = append(widths, width) } } } result, err := d.testReflow(tabID, widths, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "page-accessibility-report": tabID := cmd.Params["tab"] testsStr := cmd.Params["tests"] standard := cmd.Params["standard"] includeScreenshots := cmd.Params["include_screenshots"] == "true" timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 30 seconds) timeout := 30 if timeoutStr != "" { if t, err := strconv.Atoi(timeoutStr); err == nil { timeout = t } } // Parse tests array var tests []string if testsStr != "" { tests = strings.Split(testsStr, ",") } result, err := d.getPageAccessibilityReport(tabID, tests, standard, includeScreenshots, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "contrast-audit": tabID := cmd.Params["tab"] prioritySelectorsStr := cmd.Params["priority_selectors"] threshold := cmd.Params["threshold"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds) timeout := 10 if timeoutStr != "" { if t, err := strconv.Atoi(timeoutStr); err == nil { timeout = t } } // Parse priority selectors var prioritySelectors []string if prioritySelectorsStr != "" { prioritySelectors = strings.Split(prioritySelectorsStr, ",") } result, err := d.getContrastAudit(tabID, prioritySelectors, threshold, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "keyboard-audit": tabID := cmd.Params["tab"] checkFocusIndicators := cmd.Params["check_focus_indicators"] == "true" checkTabOrder := cmd.Params["check_tab_order"] == "true" checkKeyboardTraps := cmd.Params["check_keyboard_traps"] == "true" timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 15 seconds) timeout := 15 if timeoutStr != "" { if t, err := strconv.Atoi(timeoutStr); err == nil { timeout = t } } result, err := d.getKeyboardAudit(tabID, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } case "form-accessibility-audit": tabID := cmd.Params["tab"] formSelector := cmd.Params["form_selector"] timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 10 seconds) timeout := 10 if timeoutStr != "" { if t, err := strconv.Atoi(timeoutStr); err == nil { timeout = t } } result, err := d.getFormAccessibilityAudit(tabID, formSelector, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { response = Response{Success: true, Data: result} } default: d.debugLog("Unknown action: %s", cmd.Action) response = Response{Success: false, Error: "Unknown action"} } d.debugLog("Command %s completed, sending response: success=%v", cmd.Action, response.Success) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) d.debugLog("Response sent for command: %s", cmd.Action) } // openTab opens a new tab and returns its ID func (d *Daemon) openTab(timeout int) (string, error) { d.debugLog("Opening new tab with timeout: %d", timeout) d.mu.Lock() defer d.mu.Unlock() // Create a context with timeout if specified if timeout > 0 { d.debugLog("Using timeout context: %d seconds", timeout) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan struct { page *rod.Page tabID string err error }, 1) // Execute the tab creation in a goroutine go func() { page, err := d.browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) var tabID string if err == nil { tabID = string(page.TargetID) } done <- struct { page *rod.Page tabID string err error }{page, tabID, err} }() // Wait for either completion or timeout select { case res := <-done: if res.err != nil { return "", fmt.Errorf("failed to create new tab: %w", res.err) } // Store the tab and update history d.tabs[res.tabID] = res.page d.tabHistory = append(d.tabHistory, res.tabID) d.currentTab = res.tabID // Set up console logging for this tab d.setupConsoleLogging(res.tabID, res.page) return res.tabID, nil case <-ctx.Done(): return "", fmt.Errorf("opening tab timed out after %d seconds", timeout) } } else { // No timeout page, err := d.browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) if err != nil { return "", fmt.Errorf("failed to create new tab: %w", err) } // Use the page ID as the tab ID tabID := string(page.TargetID) d.tabs[tabID] = page // Add to tab history stack and set as current tab d.tabHistory = append(d.tabHistory, tabID) d.currentTab = tabID // Set up console logging for this tab d.setupConsoleLogging(tabID, page) return tabID, nil } } // getTabID returns the tab ID to use, falling back to the current tab if none is provided func (d *Daemon) getTabID(tabID string) (string, error) { d.mu.Lock() defer d.mu.Unlock() // If no tab ID is provided, use the current tab if tabID == "" { if d.currentTab == "" { return "", fmt.Errorf("no current tab available, please open a tab first") } return d.currentTab, nil } // Otherwise, use the provided tab ID return tabID, nil } // updateTabHistory updates the tab history stack when a tab is activated func (d *Daemon) updateTabHistory(tabID string) { // Set as current tab d.currentTab = tabID // Remove the tab from history if it's already there for i, id := range d.tabHistory { if id == tabID { // Remove this tab from history d.tabHistory = append(d.tabHistory[:i], d.tabHistory[i+1:]...) break } } // Add the tab to the end of history (most recent) d.tabHistory = append(d.tabHistory, tabID) } // findPageByID finds a page by its ID without updating the current tab or cache func (d *Daemon) findPageByID(tabID string) (*rod.Page, error) { // If not in memory, try to get the page from the browser pages, err := d.browser.Pages() if err != nil { return nil, fmt.Errorf("failed to get browser pages: %w", err) } // Find the page with the matching ID for _, p := range pages { if string(p.TargetID) == tabID { return p, nil } } return nil, fmt.Errorf("tab not found: %s", tabID) } // getTab returns a tab by its ID, checking for iframe context first func (d *Daemon) getTab(tabID string) (*rod.Page, error) { // Get the tab ID to use (may be the current tab) actualTabID, err := d.getTabID(tabID) if err != nil { return nil, err } d.mu.Lock() defer d.mu.Unlock() // First check if we have an iframe context for this tab if iframePage, exists := d.iframePages[actualTabID]; exists { // Update tab history and current tab d.updateTabHistory(actualTabID) return iframePage, nil } // Check in-memory cache for main page page, exists := d.tabs[actualTabID] if exists { // Update tab history and current tab d.updateTabHistory(actualTabID) return page, nil } // If not in memory, try to find it page, err = d.findPageByID(actualTabID) if err != nil { return nil, err } // If found, cache it for future use if page != nil { d.tabs[actualTabID] = page // Update tab history and current tab d.updateTabHistory(actualTabID) return page, nil } return nil, fmt.Errorf("tab not found: %s", actualTabID) } // closeTab closes a tab by its ID func (d *Daemon) closeTab(tabID string, timeout int) error { // Get the tab ID to use (may be the current tab) actualTabID, err := d.getTabID(tabID) if err != nil { return err } // First remove from our internal map to avoid future references d.mu.Lock() page, exists := d.tabs[actualTabID] delete(d.tabs, actualTabID) // Remove the tab from history for i, id := range d.tabHistory { if id == actualTabID { // Remove this tab from history d.tabHistory = append(d.tabHistory[:i], d.tabHistory[i+1:]...) break } } // If we closed the current tab, set it to the previous tab in history if d.currentTab == actualTabID { if len(d.tabHistory) > 0 { // Set current tab to the most recent tab in history d.currentTab = d.tabHistory[len(d.tabHistory)-1] } else { // No tabs left in history, clear the current tab d.currentTab = "" } } d.mu.Unlock() // If the page doesn't exist in our cache, try to find it if !exists { var err error page, err = d.findPageByID(actualTabID) if err != nil { // If we can't find the page, it might already be closed return nil } } if timeout > 0 { // Use timeout for closing the tab ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the close in a goroutine go func() { err := page.Close() done <- err }() // Wait for either completion or timeout select { case err := <-done: if err != nil { // Log the error but don't return it, as we've already removed it from our map fmt.Printf("Warning: failed to close tab %s: %v\n", actualTabID, err) } return nil case <-ctx.Done(): return fmt.Errorf("closing tab timed out after %d seconds", timeout) } } else { // No timeout - try to close the page, but don't fail if it's already closed err = page.Close() if err != nil { // Log the error but don't return it, as we've already removed it from our map fmt.Printf("Warning: failed to close tab %s: %v\n", actualTabID, err) } return nil } } // loadURL loads a URL in a tab func (d *Daemon) loadURL(tabID, url string, timeout int) error { d.debugLog("Loading URL: %s in tab: %s with timeout: %d", url, tabID, timeout) page, err := d.getTab(tabID) if err != nil { d.debugLog("Failed to get tab %s: %v", tabID, err) return err } d.debugLog("Got tab %s, starting navigation", tabID) if timeout > 0 { // Use timeout for the URL loading ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the navigation in a goroutine go func() { err := page.Navigate(url) if err != nil { done <- fmt.Errorf("failed to navigate to URL: %w", err) return } // Wait for the page to be loaded err = page.WaitLoad() if err != nil { done <- fmt.Errorf("failed to wait for page load: %w", err) return } done <- nil }() // Wait for either completion or timeout select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("loading URL timed out after %d seconds", timeout) } } else { // No timeout err = page.Navigate(url) if err != nil { return fmt.Errorf("failed to navigate to URL: %w", err) } // Wait for the page to be loaded err = page.WaitLoad() if err != nil { return fmt.Errorf("failed to wait for page load: %w", err) } return nil } } // isPageStable checks if a page is stable and not currently loading func (d *Daemon) isPageStable(page *rod.Page) (bool, error) { // Check if page is loading result, err := page.Eval(`() => document.readyState === 'complete'`) if err != nil { return false, err } isComplete := result.Value.Bool() if !isComplete { return false, nil } // Additional check: ensure no pending network requests // This is a simple heuristic - if the page has been stable for a short time err = page.WaitStable(500 * time.Millisecond) if err != nil { return false, nil // Page is not stable } return true, nil } // detectNavigationInProgress monitors the page for a short period to detect if navigation starts func (d *Daemon) detectNavigationInProgress(page *rod.Page, monitorDuration time.Duration) (bool, error) { // Get current URL and readyState currentURL, err := page.Eval(`() => window.location.href`) if err != nil { return false, err } currentReadyState, err := page.Eval(`() => document.readyState`) if err != nil { return false, err } startURL := currentURL.Value.Str() startReadyState := currentReadyState.Value.Str() // Monitor for changes over the specified duration ctx, cancel := context.WithTimeout(context.Background(), monitorDuration) defer cancel() ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): // No navigation detected during monitoring period return false, nil case <-ticker.C: // Check if URL or readyState changed newURL, err := page.Eval(`() => window.location.href`) if err != nil { continue // Ignore errors during monitoring } newReadyState, err := page.Eval(`() => document.readyState`) if err != nil { continue // Ignore errors during monitoring } if newURL.Value.Str() != startURL { // URL changed, navigation is happening return true, nil } if newReadyState.Value.Str() != startReadyState && newReadyState.Value.Str() == "loading" { // Page started loading return true, nil } } } } // waitNavigation waits for a navigation event to happen func (d *Daemon) waitNavigation(tabID string, timeout int) error { page, err := d.getTab(tabID) if err != nil { return err } // First, check if the page is already stable and loaded // If so, we don't need to wait for navigation isStable, err := d.isPageStable(page) if err == nil && isStable { // Page is already stable, no navigation happening return nil } // Check if navigation is actually in progress by monitoring for a short period navigationDetected, err := d.detectNavigationInProgress(page, 2*time.Second) if err != nil { return fmt.Errorf("failed to detect navigation state: %w", err) } if !navigationDetected { // No navigation detected, check if page is stable now isStable, err := d.isPageStable(page) if err == nil && isStable { return nil } // If we can't determine stability, proceed with waiting } // Navigation is in progress, wait for it to complete ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Wait for navigation with timeout done := make(chan error, 1) go func() { defer func() { if r := recover(); r != nil { done <- fmt.Errorf("navigation wait panicked: %v", r) } }() // Wait for navigation event page.WaitNavigation(proto.PageLifecycleEventNameLoad)() // Wait for the page to be fully loaded err := page.WaitLoad() done <- err }() select { case err := <-done: if err != nil { return fmt.Errorf("navigation wait failed: %w", err) } return nil case <-ctx.Done(): // Timeout occurred, check if page is now stable isStable, err := d.isPageStable(page) if err == nil && isStable { // Page is stable, consider navigation complete return nil } return fmt.Errorf("navigation wait timed out after %d seconds", timeout) } } // getPageSource returns the entire source code of a page func (d *Daemon) getPageSource(tabID string, timeout int) (string, error) { page, err := d.getTab(tabID) if err != nil { return "", err } if timeout > 0 { // Use timeout for getting page source ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan struct { html string err error }, 1) // Execute the HTML retrieval in a goroutine go func() { html, err := page.HTML() done <- struct { html string err error }{html, err} }() // Wait for either completion or timeout select { case res := <-done: if res.err != nil { return "", fmt.Errorf("failed to get page HTML: %w", res.err) } return res.html, nil case <-ctx.Done(): return "", fmt.Errorf("getting page source timed out after %d seconds", timeout) } } else { // No timeout html, err := page.HTML() if err != nil { return "", fmt.Errorf("failed to get page HTML: %w", err) } return html, nil } } // getElementHTML returns the HTML of an element at the specified selector func (d *Daemon) getElementHTML(tabID, selector string, selectionTimeout int) (string, error) { page, err := d.getTab(tabID) if err != nil { return "", err } // Find the element with optional timeout var element *rod.Element if selectionTimeout > 0 { // Use timeout if specified element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) if err != nil { return "", fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err) } } else { // No timeout element, err = page.Element(selector) if err != nil { return "", fmt.Errorf("failed to find element: %w", err) } } // Get the HTML of the element html, err := element.HTML() if err != nil { return "", fmt.Errorf("failed to get element HTML: %w", err) } return html, nil } // fillFormField fills a form field with the specified value func (d *Daemon) fillFormField(tabID, selector, value string, selectionTimeout, actionTimeout int) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element with optional timeout var element *rod.Element if selectionTimeout > 0 { // Use timeout if specified element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) if err != nil { return fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err) } } else { // No timeout element, err = page.Element(selector) if err != nil { return fmt.Errorf("failed to find element: %w", err) } } // Get the element type tagName, err := element.Eval(`() => this.tagName.toLowerCase()`) if err != nil { return fmt.Errorf("failed to get element type: %w", err) } // Get the element type attribute inputType, err := element.Eval(`() => this.type ? this.type.toLowerCase() : ''`) if err != nil { return fmt.Errorf("failed to get element type attribute: %w", err) } // Handle different input types tagNameStr := tagName.Value.String() typeStr := inputType.Value.String() // Handle checkbox and radio inputs if tagNameStr == "input" && (typeStr == "checkbox" || typeStr == "radio") { // Convert value to boolean checked := false if value == "true" || value == "1" || value == "yes" || value == "on" || value == "checked" { checked = true } // Set the checked state with optional timeout if actionTimeout > 0 { // Use timeout for the action ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the action in a goroutine go func() { _, err := element.Eval(fmt.Sprintf(`() => { this.checked = %t; return true; }`, checked)) done <- err }() // Wait for either completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to set checkbox state: %w", err) } case <-ctx.Done(): return fmt.Errorf("setting checkbox state timed out after %d seconds", actionTimeout) } // Create a channel for the event trigger done = make(chan error, 1) // Trigger change event with timeout go func() { _, err := element.Eval(`() => { const event = new Event('change', { bubbles: true }); this.dispatchEvent(event); return true; }`) done <- err }() // Wait for either completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to trigger change event: %w", err) } case <-ctx.Done(): return fmt.Errorf("triggering change event timed out after %d seconds", actionTimeout) } } else { // No timeout _, err := element.Eval(fmt.Sprintf(`() => { this.checked = %t; return true; }`, checked)) if err != nil { return fmt.Errorf("failed to set checkbox state: %w", err) } // Trigger change event _, err = element.Eval(`() => { const event = new Event('change', { bubbles: true }); this.dispatchEvent(event); return true; }`) if err != nil { return fmt.Errorf("failed to trigger change event: %w", err) } } return nil } // For regular text inputs if actionTimeout > 0 { // Use timeout for the action ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Clear the field first with timeout go func() { _ = element.SelectAllText() err := element.Input("") done <- err }() // Wait for either completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to clear field: %w", err) } case <-ctx.Done(): return fmt.Errorf("clearing field timed out after %d seconds", actionTimeout) } // Create a channel for the input action done = make(chan error, 1) // Input the value with timeout go func() { err := element.Input(value) done <- err }() // Wait for either completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to input value: %w", err) } case <-ctx.Done(): return fmt.Errorf("inputting value timed out after %d seconds", actionTimeout) } } else { // No timeout // Clear the field first _ = element.SelectAllText() err = element.Input("") if err != nil { return fmt.Errorf("failed to clear field: %w", err) } // Input the value err = element.Input(value) if err != nil { return fmt.Errorf("failed to input value: %w", err) } } return nil } // uploadFile uploads a file to a file input element func (d *Daemon) uploadFile(tabID, selector, filePath string, selectionTimeout, actionTimeout int) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element with optional timeout var element *rod.Element if selectionTimeout > 0 { // Use timeout if specified element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) if err != nil { return fmt.Errorf("failed to find file input element (timeout after %ds): %w", selectionTimeout, err) } } else { // No timeout element, err = page.Element(selector) if err != nil { return fmt.Errorf("failed to find file input element: %w", err) } } // Set the file with optional timeout if actionTimeout > 0 { // Use timeout for the action ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the action in a goroutine go func() { err := element.SetFiles([]string{filePath}) done <- err }() // Wait for either completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to set file: %w", err) } case <-ctx.Done(): return fmt.Errorf("setting file timed out after %d seconds", actionTimeout) } } else { // No timeout err = element.SetFiles([]string{filePath}) if err != nil { return fmt.Errorf("failed to set file: %w", err) } } return nil } // submitForm submits a form func (d *Daemon) submitForm(tabID, selector string, selectionTimeout, actionTimeout int) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element with optional timeout var form *rod.Element if selectionTimeout > 0 { // Use timeout if specified form, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) if err != nil { return fmt.Errorf("failed to find form element (timeout after %ds): %w", selectionTimeout, err) } } else { // No timeout form, err = page.Element(selector) if err != nil { return fmt.Errorf("failed to find form element: %w", err) } } // Get the current URL to detect navigation currentURL := page.MustInfo().URL // Create a context for navigation timeout var ctx context.Context var cancel context.CancelFunc // Submit the form with optional timeout if actionTimeout > 0 { // Use timeout for the action submitCtx, submitCancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) defer submitCancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the action in a goroutine go func() { _, err := form.Eval(`() => { try { this.submit(); return true; } catch(e) { return false; } }`) done <- err }() // Wait for either completion or timeout select { case err := <-done: if err != nil { // Log the error but continue fmt.Printf("Warning: error during form submission: %v\n", err) } case <-submitCtx.Done(): return fmt.Errorf("form submission timed out after %d seconds", actionTimeout) } // Wait for navigation to complete (with the same timeout) ctx, cancel = context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) } else { // No timeout for submission try := func() (bool, error) { _, err := form.Eval(`() => { try { this.submit(); return true; } catch(e) { return false; } }`) return err == nil, err } // Try to submit the form, but don't fail if it's already been submitted _, err = try() if err != nil { // Log the error but continue fmt.Printf("Warning: error during form submission: %v\n", err) } // Wait for navigation to complete (with default timeout) ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) } defer cancel() // Wait for the page to navigate away from the current URL waitNav := func() error { for { select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): // Check if the page has navigated try := func() (string, error) { info, err := page.Info() if err != nil { return "", err } return info.URL, nil } newURL, err := try() if err != nil { // Page might be navigating, wait a bit more continue } if newURL != currentURL { // Navigation completed return nil } } } } // Wait for navigation but don't fail if it times out err = waitNav() if err != nil { // Log the error but don't fail fmt.Printf("Warning: navigation after form submission may not have completed: %v\n", err) } return nil } // clickElement clicks on an element func (d *Daemon) clickElement(tabID, selector string, selectionTimeout, actionTimeout int) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element with optional timeout var element *rod.Element if selectionTimeout > 0 { // Use timeout if specified element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) if err != nil { return fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err) } } else { // No timeout element, err = page.Element(selector) if err != nil { return fmt.Errorf("failed to find element: %w", err) } } // Make sure the element is visible and scrolled into view err = element.ScrollIntoView() if err != nil { return fmt.Errorf("failed to scroll element into view: %w", err) } // Click the element with optional timeout if actionTimeout > 0 { // Use timeout for the click action ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the click in a goroutine go func() { err := element.Click(proto.InputMouseButtonLeft, 1) // 1 click done <- err }() // Wait for either completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to click element: %w", err) } case <-ctx.Done(): return fmt.Errorf("click action timed out after %d seconds", actionTimeout) } } else { // No timeout err = element.Click(proto.InputMouseButtonLeft, 1) // 1 click if err != nil { return fmt.Errorf("failed to click element: %w", err) } } // Wait a moment for any navigation to start time.Sleep(100 * time.Millisecond) // Wait for any potential page load or DOM changes err = page.WaitStable(1 * time.Second) if err != nil { // This is not a critical error, so we'll just log it log.Printf("Warning: page not stable after click: %v", err) } return nil } // selectElement selects an option in a select dropdown func (d *Daemon) selectElement(tabID, selector, value string, selectionTimeout, actionTimeout int) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element with optional timeout var element *rod.Element if selectionTimeout > 0 { // Use timeout if specified element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) if err != nil { return fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err) } } else { // No timeout element, err = page.Element(selector) if err != nil { return fmt.Errorf("failed to find element: %w", err) } } // Make sure the element is visible and scrolled into view err = element.ScrollIntoView() if err != nil { return fmt.Errorf("failed to scroll element into view: %w", err) } // For select elements, use rod's built-in Select method // Try to select by text first (most common case) err = element.Select([]string{value}, true, rod.SelectorTypeText) if err != nil { // If text selection failed, use JavaScript as fallback // Use a simple single statement that works with rod's evaluation script := fmt.Sprintf("document.querySelector(\"%s\").value = \"%s\"", selector, value) // Execute the value assignment (ignore evaluation errors, rod has issues with some JS patterns) page.Eval(script) // Dispatch the change event separately changeScript := fmt.Sprintf("document.querySelector(\"%s\").dispatchEvent(new Event(\"change\", { bubbles: true }))", selector) page.Eval(changeScript) // Verify the selection worked by checking the element's value property directly currentValue, err := element.Property("value") if err != nil { return fmt.Errorf("failed to verify selection: %w", err) } // Check if the selection actually worked if currentValue.Str() != value { return fmt.Errorf("failed to select option '%s' in element (current value: %s)", value, currentValue.Str()) } } return nil } // evalJS executes JavaScript code in a tab and returns the result func (d *Daemon) evalJS(tabID, jsCode string, timeout int) (string, error) { page, err := d.getTab(tabID) if err != nil { return "", err } // Create a comprehensive wrapper that handles both expressions and statements // and properly formats the result wrappedCode := `() => { var result; try { // Try to evaluate as an expression first result = eval(` + "`" + jsCode + "`" + `); } catch(e) { // If that fails, try to execute as statements try { eval(` + "`" + jsCode + "`" + `); result = undefined; } catch(e2) { throw e; // Re-throw the original error } } // Format the result for return if (typeof result === 'undefined') return 'undefined'; if (result === null) return 'null'; if (typeof result === 'string') return result; if (typeof result === 'number' || typeof result === 'boolean') return String(result); try { return JSON.stringify(result); } catch(e) { return String(result); } }` // Execute the wrapped JavaScript code with timeout if timeout > 0 { // Use timeout for the JavaScript execution ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan struct { result string err error }, 1) // Execute the JavaScript in a goroutine go func() { result, err := page.Eval(wrappedCode) var resultStr string if err == nil { // Convert the result to a string representation if result.Value.Nil() { resultStr = "null" } else { resultStr = result.Value.String() } } done <- struct { result string err error }{resultStr, err} }() // Wait for either completion or timeout select { case res := <-done: if res.err != nil { return "", fmt.Errorf("failed to execute JavaScript: %w", res.err) } return res.result, nil case <-ctx.Done(): return "", fmt.Errorf("JavaScript execution timed out after %d seconds", timeout) } } else { // No timeout result, err := page.Eval(wrappedCode) if err != nil { return "", fmt.Errorf("failed to execute JavaScript: %w", err) } // Convert the result to a string representation if result.Value.Nil() { return "null", nil } return result.Value.String(), nil } } // takeScreenshot takes a screenshot of a tab and saves it to a file func (d *Daemon) takeScreenshot(tabID, outputPath string, fullPage bool, timeout int) error { page, err := d.getTab(tabID) if err != nil { return err } if timeout > 0 { // Use timeout for taking screenshot ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the screenshot in a goroutine go func() { // Take screenshot and save it screenshotBytes, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ Format: proto.PageCaptureScreenshotFormatPng, }) if err != nil { done <- fmt.Errorf("failed to capture screenshot: %w", err) return } // Write the screenshot to file err = os.WriteFile(outputPath, screenshotBytes, 0644) if err != nil { done <- fmt.Errorf("failed to save screenshot to %s: %w", outputPath, err) return } done <- nil }() // Wait for either completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to save screenshot: %w", err) } return nil case <-ctx.Done(): return fmt.Errorf("taking screenshot timed out after %d seconds", timeout) } } else { // No timeout - take screenshot directly screenshotBytes, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ Format: proto.PageCaptureScreenshotFormatPng, }) if err != nil { return fmt.Errorf("failed to capture screenshot: %w", err) } // Write the screenshot to file err = os.WriteFile(outputPath, screenshotBytes, 0644) if err != nil { return fmt.Errorf("failed to save screenshot to %s: %w", outputPath, err) } return nil } } // takeScreenshotEnhanced takes a screenshot with optional zoom level and viewport size func (d *Daemon) takeScreenshotEnhanced(tabID, outputPath string, fullPage bool, zoomLevel float64, viewportWidth, viewportHeight, timeout int) error { page, err := d.getTab(tabID) if err != nil { return err } // Store original viewport settings if we need to change them var originalViewport *proto.EmulationSetDeviceMetricsOverride needsReset := false // Get current viewport if we need to modify it if zoomLevel > 0 || viewportWidth > 0 || viewportHeight > 0 { currentViewport, err := page.Eval(`() => { return { width: window.innerWidth, height: window.innerHeight }; }`) if err == nil { var viewportData struct { Width int `json:"width"` Height int `json:"height"` } json.Unmarshal([]byte(currentViewport.Value.String()), &viewportData) originalViewport = &proto.EmulationSetDeviceMetricsOverride{ Width: viewportData.Width, Height: viewportData.Height, DeviceScaleFactor: 1.0, Mobile: false, } needsReset = true // Set new viewport settings newWidth := viewportData.Width newHeight := viewportData.Height newZoom := 1.0 if viewportWidth > 0 { newWidth = viewportWidth } if viewportHeight > 0 { newHeight = viewportHeight } if zoomLevel > 0 { newZoom = zoomLevel } err = page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ Width: newWidth, Height: newHeight, DeviceScaleFactor: newZoom, Mobile: newWidth <= 768, }) if err != nil { return fmt.Errorf("failed to set viewport: %w", err) } // Wait for reflow time.Sleep(500 * time.Millisecond) } } // Take the screenshot var screenshotErr error if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() done := make(chan error, 1) go func() { screenshotBytes, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ Format: proto.PageCaptureScreenshotFormatPng, }) if err != nil { done <- fmt.Errorf("failed to capture screenshot: %w", err) return } err = os.WriteFile(outputPath, screenshotBytes, 0644) if err != nil { done <- fmt.Errorf("failed to save screenshot to %s: %w", outputPath, err) return } done <- nil }() select { case err := <-done: screenshotErr = err case <-ctx.Done(): screenshotErr = fmt.Errorf("taking screenshot timed out after %d seconds", timeout) } } else { screenshotBytes, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ Format: proto.PageCaptureScreenshotFormatPng, }) if err != nil { screenshotErr = fmt.Errorf("failed to capture screenshot: %w", err) } else { err = os.WriteFile(outputPath, screenshotBytes, 0644) if err != nil { screenshotErr = fmt.Errorf("failed to save screenshot to %s: %w", outputPath, err) } } } // Reset viewport if we changed it if needsReset && originalViewport != nil { err = page.SetViewport(originalViewport) if err != nil { d.debugLog("Warning: Failed to reset viewport: %v", err) } } return screenshotErr } // switchToIframe switches the context to an iframe for subsequent commands func (d *Daemon) switchToIframe(tabID, selector string, timeout int) error { d.debugLog("Switching to iframe: selector=%s, tab=%s, timeout=%d", selector, tabID, timeout) // Get the main page first (not iframe context) actualTabID, err := d.getTabID(tabID) if err != nil { d.debugLog("Failed to get tab ID: %v", err) return err } d.mu.Lock() defer d.mu.Unlock() // Get the main page (bypass iframe context) mainPage, exists := d.tabs[actualTabID] if !exists { d.debugLog("Tab %s not in cache, trying to find it", actualTabID) // Try to find it mainPage, err = d.findPageByID(actualTabID) if err != nil { d.debugLog("Failed to find tab %s: %v", actualTabID, err) return err } if mainPage == nil { d.debugLog("Tab %s not found", actualTabID) return fmt.Errorf("tab not found: %s", actualTabID) } d.tabs[actualTabID] = mainPage } d.debugLog("Found main page for tab %s, looking for iframe element", actualTabID) // Find the iframe element with timeout var iframeElement *rod.Element if timeout > 0 { // Use timeout context for finding the iframe element ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan struct { element *rod.Element err error }, 1) // Execute the element search in a goroutine go func() { defer func() { if r := recover(); r != nil { done <- struct { element *rod.Element err error }{nil, fmt.Errorf("iframe element search panicked: %v", r)} } }() element, err := mainPage.Timeout(time.Duration(timeout) * time.Second).Element(selector) done <- struct { element *rod.Element err error }{element, err} }() // Wait for either completion or timeout select { case result := <-done: iframeElement = result.element err = result.err case <-ctx.Done(): d.debugLog("Iframe element search timed out after %d seconds", timeout) return fmt.Errorf("failed to find iframe element (timeout after %ds): %s", timeout, selector) } } else { // No timeout iframeElement, err = mainPage.Element(selector) } if err != nil { d.debugLog("Failed to find iframe element: %v", err) return fmt.Errorf("failed to find iframe element: %w", err) } d.debugLog("Found iframe element, getting frame context") // Get the iframe's page context with timeout var iframePage *rod.Page if timeout > 0 { // Use timeout context for getting the frame ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan struct { page *rod.Page err error }, 1) // Execute the frame access in a goroutine go func() { defer func() { if r := recover(); r != nil { done <- struct { page *rod.Page err error }{nil, fmt.Errorf("iframe frame access panicked: %v", r)} } }() page, err := iframeElement.Frame() done <- struct { page *rod.Page err error }{page, err} }() // Wait for either completion or timeout select { case result := <-done: iframePage = result.page err = result.err case <-ctx.Done(): d.debugLog("Iframe frame access timed out after %d seconds", timeout) return fmt.Errorf("failed to get iframe context (timeout after %ds)", timeout) } } else { // No timeout iframePage, err = iframeElement.Frame() } if err != nil { d.debugLog("Failed to get iframe context: %v", err) return fmt.Errorf("failed to get iframe context: %w", err) } // Store the iframe page context d.iframePages[actualTabID] = iframePage d.debugLog("Successfully switched to iframe context for tab %s", actualTabID) return nil } // switchToMain switches back to the main page context func (d *Daemon) switchToMain(tabID string) error { d.debugLog("Switching back to main context: tab=%s", tabID) // Get the tab ID to use (may be the current tab) actualTabID, err := d.getTabID(tabID) if err != nil { d.debugLog("Failed to get tab ID: %v", err) return err } d.mu.Lock() defer d.mu.Unlock() // Check if there was an iframe context to remove if _, exists := d.iframePages[actualTabID]; exists { d.debugLog("Removing iframe context for tab %s", actualTabID) // Remove the iframe context for this tab delete(d.iframePages, actualTabID) } else { d.debugLog("No iframe context found for tab %s", actualTabID) } d.debugLog("Successfully switched back to main context for tab %s", actualTabID) return nil } // handleFileUpload handles file upload requests from clients func (d *Daemon) handleFileUpload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Parse multipart form (32MB max memory) err := r.ParseMultipartForm(32 << 20) if err != nil { http.Error(w, "Failed to parse multipart form", http.StatusBadRequest) return } // Get the uploaded file file, header, err := r.FormFile("file") if err != nil { http.Error(w, "Failed to get uploaded file", http.StatusBadRequest) return } defer file.Close() // Get the target path (optional, defaults to /tmp/) targetPath := r.FormValue("path") if targetPath == "" { targetPath = "/tmp/" + header.Filename } // Create the target file targetFile, err := os.Create(targetPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to create target file: %v", err), http.StatusInternalServerError) return } defer targetFile.Close() // Copy the uploaded file to the target location _, err = io.Copy(targetFile, file) if err != nil { http.Error(w, fmt.Sprintf("Failed to save file: %v", err), http.StatusInternalServerError) return } // Return success response response := Response{ Success: true, Data: map[string]interface{}{ "filename": header.Filename, "size": header.Size, "target_path": targetPath, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // handleFileDownload handles file download requests from clients func (d *Daemon) handleFileDownload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Get the file path from query parameter filePath := r.URL.Query().Get("path") if filePath == "" { http.Error(w, "File path is required", http.StatusBadRequest) return } // Check if file exists and get info fileInfo, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { http.Error(w, "File not found", http.StatusNotFound) } else { http.Error(w, fmt.Sprintf("Failed to access file: %v", err), http.StatusInternalServerError) } return } // Open the file file, err := os.Open(filePath) if err != nil { http.Error(w, fmt.Sprintf("Failed to open file: %v", err), http.StatusInternalServerError) return } defer file.Close() // Set headers for file download w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileInfo.Name())) w.Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size(), 10)) // Stream the file to the client _, err = io.Copy(w, file) if err != nil { log.Printf("Error streaming file to client: %v", err) } } // setupConsoleLogging sets up console log capture for a tab func (d *Daemon) setupConsoleLogging(tabID string, page *rod.Page) { // Initialize console logs for this tab d.consoleLogs[tabID] = make([]ConsoleLog, 0) // Listen for console events go page.EachEvent(func(e *proto.RuntimeConsoleAPICalled) { d.mu.Lock() defer d.mu.Unlock() // Convert console level level := string(e.Type) // Build message from arguments var message string for i, arg := range e.Args { if i > 0 { message += " " } // Handle different argument types if !arg.Value.Nil() { message += arg.Value.String() } else if arg.Description != "" { message += arg.Description } else { message += "[object]" } } // Create console log entry logEntry := ConsoleLog{ Level: level, Message: message, Timestamp: time.Now(), Source: "", // Could be enhanced with stack trace info } // Add to console logs (keep last 1000 entries per tab) logs := d.consoleLogs[tabID] logs = append(logs, logEntry) if len(logs) > 1000 { logs = logs[1:] // Remove oldest entry } d.consoleLogs[tabID] = logs })() } // getConsoleLogs retrieves console logs for a tab func (d *Daemon) getConsoleLogs(tabID string, clear bool) ([]ConsoleLog, error) { d.mu.Lock() defer d.mu.Unlock() // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } // Check if tab exists if _, exists := d.tabs[tabID]; !exists { return nil, fmt.Errorf("tab not found: %s", tabID) } // Get logs for this tab logs, exists := d.consoleLogs[tabID] if !exists { logs = make([]ConsoleLog, 0) } // Clear logs if requested if clear { d.consoleLogs[tabID] = make([]ConsoleLog, 0) } return logs, nil } // executeConsoleCommand executes a command in the browser console func (d *Daemon) executeConsoleCommand(tabID, command string, timeout int) (string, error) { // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return "", fmt.Errorf("no tab specified and no current tab available") } // Execute the command as JavaScript and return the result // This is similar to evalJS but specifically for console commands return d.evalJS(tabID, command, timeout) } // ElementCheckResult represents the result of an element check type ElementCheckResult struct { Exists bool `json:"exists"` Visible bool `json:"visible,omitempty"` Enabled bool `json:"enabled,omitempty"` Focused bool `json:"focused,omitempty"` Selected bool `json:"selected,omitempty"` Count int `json:"count,omitempty"` } // MultipleExtractionResult represents the result of extracting from multiple selectors type MultipleExtractionResult struct { Results map[string]interface{} `json:"results"` Errors map[string]string `json:"errors,omitempty"` } // LinkInfo represents information about a link type LinkInfo struct { Href string `json:"href"` Text string `json:"text"` Title string `json:"title,omitempty"` Target string `json:"target,omitempty"` } // LinksExtractionResult represents the result of extracting links type LinksExtractionResult struct { Links []LinkInfo `json:"links"` Count int `json:"count"` } // TableExtractionResult represents the result of extracting table data type TableExtractionResult struct { Headers []string `json:"headers,omitempty"` Rows [][]string `json:"rows"` Data []map[string]string `json:"data,omitempty"` // Only if headers are included Count int `json:"count"` } // TextExtractionResult represents the result of extracting text type TextExtractionResult struct { Text string `json:"text"` Matches []string `json:"matches,omitempty"` // If pattern was used Count int `json:"count"` // Number of elements matched } // checkElement checks various states of an element func (d *Daemon) checkElement(tabID, selector, checkType string, timeout int) (*ElementCheckResult, error) { // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } page, exists := d.tabs[tabID] if !exists { return nil, fmt.Errorf("tab %s not found", tabID) } // Check if we're in iframe mode for this tab if iframePage, inIframe := d.iframePages[tabID]; inIframe { page = iframePage } result := &ElementCheckResult{} // First check if element exists elements, err := page.Elements(selector) if err != nil { // If we can't find elements, it means they don't exist result.Exists = false return result, nil } result.Exists = len(elements) > 0 result.Count = len(elements) // If no elements exist, return early if !result.Exists { return result, nil } // For additional checks, use the first element element := elements[0] switch checkType { case "exists": // Already handled above case "visible": visible, err := element.Visible() if err != nil { return nil, fmt.Errorf("failed to check visibility: %w", err) } result.Visible = visible case "enabled": // Check if element is enabled (not disabled) disabled, err := element.Attribute("disabled") if err != nil { return nil, fmt.Errorf("failed to check enabled state: %w", err) } result.Enabled = disabled == nil case "focused": // Check if element is focused jsCode := fmt.Sprintf("document.activeElement === document.querySelector('%s')", selector) focusResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to check focus state: %w", err) } result.Focused = focusResult.Value.Bool() case "selected": // Check if element is selected (for checkboxes, radio buttons, options) selected, err := element.Attribute("selected") if err == nil && selected != nil { result.Selected = true } else { // Also check 'checked' attribute for checkboxes and radio buttons checked, err := element.Attribute("checked") if err == nil && checked != nil { result.Selected = true } else { result.Selected = false } } case "all": // Check all states visible, _ := element.Visible() result.Visible = visible disabled, _ := element.Attribute("disabled") result.Enabled = disabled == nil jsCode := fmt.Sprintf("document.activeElement === document.querySelector('%s')", selector) focusResult, _ := page.Eval(jsCode) if focusResult != nil { result.Focused = focusResult.Value.Bool() } selected, _ := element.Attribute("selected") if selected != nil { result.Selected = true } else { checked, _ := element.Attribute("checked") result.Selected = checked != nil } default: return nil, fmt.Errorf("unknown check type: %s", checkType) } return result, nil } // getElementAttributes gets attributes, properties, and computed styles of an element func (d *Daemon) getElementAttributes(tabID, selector, attributes string, timeout int) (map[string]interface{}, error) { // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } page, exists := d.tabs[tabID] if !exists { return nil, fmt.Errorf("tab %s not found", tabID) } // Check if we're in iframe mode for this tab if iframePage, inIframe := d.iframePages[tabID]; inIframe { page = iframePage } // Find the element with timeout var element *rod.Element if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() var err error element, err = page.Context(ctx).Element(selector) if err != nil { return nil, fmt.Errorf("element not found: %w", err) } } else { var err error element, err = page.Element(selector) if err != nil { return nil, fmt.Errorf("element not found: %w", err) } } result := make(map[string]interface{}) if attributes == "all" { // Get all common attributes and properties commonAttrs := []string{ "id", "class", "name", "type", "value", "href", "src", "alt", "title", "disabled", "checked", "selected", "readonly", "required", "placeholder", "data-*", "aria-*", } // Get HTML attributes for _, attr := range commonAttrs { if attr == "data-*" || attr == "aria-*" { // Skip wildcard attributes for now continue } value, err := element.Attribute(attr) if err == nil && value != nil { result[attr] = *value } } // Get common properties via JavaScript jsCode := ` (function(el) { return { tagName: el.tagName, textContent: el.textContent, innerHTML: el.innerHTML, outerHTML: el.outerHTML, offsetWidth: el.offsetWidth, offsetHeight: el.offsetHeight, scrollWidth: el.scrollWidth, scrollHeight: el.scrollHeight, clientWidth: el.clientWidth, clientHeight: el.clientHeight }; })(arguments[0]) ` jsResult, err := element.Eval(jsCode) if err == nil { if props := jsResult.Value.Map(); props != nil { for key, value := range props { result[key] = value } } } // Get computed styles for common properties styleProps := []string{ "display", "visibility", "opacity", "position", "top", "left", "width", "height", "margin", "padding", "border", "background-color", "color", "font-size", "font-family", } for _, prop := range styleProps { jsCode := fmt.Sprintf("getComputedStyle(arguments[0]).%s", prop) styleResult, err := element.Eval(jsCode) if err == nil { result["style_"+prop] = styleResult.Value.Str() } } } else { // Get specific attributes (comma-separated) attrList := []string{} if attributes != "" { // Split by comma and trim spaces for _, attr := range strings.Split(attributes, ",") { attrList = append(attrList, strings.TrimSpace(attr)) } } for _, attr := range attrList { if strings.HasPrefix(attr, "style_") { // Get computed style styleProp := strings.TrimPrefix(attr, "style_") jsCode := fmt.Sprintf("getComputedStyle(arguments[0]).%s", styleProp) styleResult, err := element.Eval(jsCode) if err == nil { result[attr] = styleResult.Value.Str() } } else if strings.HasPrefix(attr, "prop_") { // Get JavaScript property propName := strings.TrimPrefix(attr, "prop_") jsCode := fmt.Sprintf("arguments[0].%s", propName) propResult, err := element.Eval(jsCode) if err == nil { result[attr] = propResult.Value.Raw } } else { // Get HTML attribute value, err := element.Attribute(attr) if err == nil && value != nil { result[attr] = *value } } } } return result, nil } // countElements counts the number of elements matching a selector func (d *Daemon) countElements(tabID, selector string, timeout int) (int, error) { // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return 0, fmt.Errorf("no tab specified and no current tab available") } page, exists := d.tabs[tabID] if !exists { return 0, fmt.Errorf("tab %s not found", tabID) } // Check if we're in iframe mode for this tab if iframePage, inIframe := d.iframePages[tabID]; inIframe { page = iframePage } // Find elements with timeout var elements rod.Elements var err error if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() elements, err = page.Context(ctx).Elements(selector) } else { elements, err = page.Elements(selector) } if err != nil { // If we can't find elements, return 0 (not an error) return 0, nil } return len(elements), nil } // extractMultiple extracts data from multiple selectors in a single call func (d *Daemon) extractMultiple(tabID, selectorsJSON string, timeout int) (*MultipleExtractionResult, error) { // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } page, exists := d.tabs[tabID] if !exists { return nil, fmt.Errorf("tab %s not found", tabID) } // Check if we're in iframe mode for this tab if iframePage, inIframe := d.iframePages[tabID]; inIframe { page = iframePage } // Parse selectors JSON var selectors map[string]string if err := json.Unmarshal([]byte(selectorsJSON), &selectors); err != nil { return nil, fmt.Errorf("invalid selectors JSON: %w", err) } result := &MultipleExtractionResult{ Results: make(map[string]interface{}), Errors: make(map[string]string), } // Extract from each selector for key, selector := range selectors { var elements rod.Elements var err error if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() elements, err = page.Context(ctx).Elements(selector) } else { elements, err = page.Elements(selector) } if err != nil { result.Errors[key] = err.Error() continue } if len(elements) == 0 { result.Results[key] = nil continue } // Extract content from all matching elements var values []string for _, element := range elements { var value string var err error // Check if it's a form input element and get its value tagName, _ := element.Eval("() => this.tagName.toLowerCase()") if tagName.Value.Str() == "input" || tagName.Value.Str() == "textarea" || tagName.Value.Str() == "select" { // For form elements, get the value property valueProp, err := element.Property("value") if err == nil && valueProp.Str() != "" { value = valueProp.Str() } else { // Fallback to text content value, err = element.Text() } } else { // For non-form elements, get text content value, err = element.Text() } if err != nil { result.Errors[key] = fmt.Sprintf("failed to get content: %v", err) break } values = append(values, value) } if len(values) == 1 { result.Results[key] = values[0] } else { result.Results[key] = values } } return result, nil } // extractLinks extracts all links from the page with optional filtering func (d *Daemon) extractLinks(tabID, containerSelector, hrefPattern, textPattern string, timeout int) (*LinksExtractionResult, error) { // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } page, exists := d.tabs[tabID] if !exists { return nil, fmt.Errorf("tab %s not found", tabID) } // Check if we're in iframe mode for this tab if iframePage, inIframe := d.iframePages[tabID]; inIframe { page = iframePage } // Build selector for links linkSelector := "a[href]" if containerSelector != "" { linkSelector = containerSelector + " " + linkSelector } // Find all links var elements rod.Elements var err error if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() elements, err = page.Context(ctx).Elements(linkSelector) } else { elements, err = page.Elements(linkSelector) } if err != nil { return nil, fmt.Errorf("failed to find links: %w", err) } result := &LinksExtractionResult{ Links: make([]LinkInfo, 0), Count: 0, } // Compile regex patterns if provided var hrefRegex, textRegex *regexp.Regexp if hrefPattern != "" { hrefRegex, err = regexp.Compile(hrefPattern) if err != nil { return nil, fmt.Errorf("invalid href pattern: %w", err) } } if textPattern != "" { textRegex, err = regexp.Compile(textPattern) if err != nil { return nil, fmt.Errorf("invalid text pattern: %w", err) } } // Extract link information for _, element := range elements { href, err := element.Attribute("href") if err != nil || href == nil { continue } text, err := element.Text() if err != nil { text = "" } // Apply filters if hrefRegex != nil && !hrefRegex.MatchString(*href) { continue } if textRegex != nil && !textRegex.MatchString(text) { continue } // Get additional attributes title, _ := element.Attribute("title") target, _ := element.Attribute("target") linkInfo := LinkInfo{ Href: *href, Text: text, } if title != nil { linkInfo.Title = *title } if target != nil { linkInfo.Target = *target } result.Links = append(result.Links, linkInfo) } result.Count = len(result.Links) return result, nil } // extractTable extracts table data as structured JSON func (d *Daemon) extractTable(tabID, selector string, includeHeaders bool, timeout int) (*TableExtractionResult, error) { // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } page, exists := d.tabs[tabID] if !exists { return nil, fmt.Errorf("tab %s not found", tabID) } // Check if we're in iframe mode for this tab if iframePage, inIframe := d.iframePages[tabID]; inIframe { page = iframePage } // Find the table var table *rod.Element var err error if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() table, err = page.Context(ctx).Element(selector) } else { table, err = page.Element(selector) } if err != nil { return nil, fmt.Errorf("failed to find table: %w", err) } result := &TableExtractionResult{ Rows: make([][]string, 0), Count: 0, } // Extract headers if requested if includeHeaders { headerRows, err := table.Elements("thead tr, tr:first-child") if err == nil && len(headerRows) > 0 { headerCells, err := headerRows[0].Elements("th, td") if err == nil { headers := make([]string, 0) for _, cell := range headerCells { text, err := cell.Text() if err != nil { text = "" } headers = append(headers, strings.TrimSpace(text)) } result.Headers = headers } } } // Extract all rows rows, err := table.Elements("tbody tr, tr") if err != nil { return nil, fmt.Errorf("failed to find table rows: %w", err) } // Skip header row if we extracted headers startIndex := 0 if includeHeaders && len(result.Headers) > 0 { startIndex = 1 } for i := startIndex; i < len(rows); i++ { cells, err := rows[i].Elements("td, th") if err != nil { continue } rowData := make([]string, 0) for _, cell := range cells { text, err := cell.Text() if err != nil { text = "" } rowData = append(rowData, strings.TrimSpace(text)) } if len(rowData) > 0 { result.Rows = append(result.Rows, rowData) } } // Create structured data if headers are available if includeHeaders && len(result.Headers) > 0 { result.Data = make([]map[string]string, 0) for _, row := range result.Rows { rowMap := make(map[string]string) for i, header := range result.Headers { if i < len(row) { rowMap[header] = row[i] } else { rowMap[header] = "" } } result.Data = append(result.Data, rowMap) } } result.Count = len(result.Rows) return result, nil } // extractText extracts text content with optional pattern matching func (d *Daemon) extractText(tabID, selector, pattern, extractType string, timeout int) (*TextExtractionResult, error) { // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } page, exists := d.tabs[tabID] if !exists { return nil, fmt.Errorf("tab %s not found", tabID) } // Check if we're in iframe mode for this tab if iframePage, inIframe := d.iframePages[tabID]; inIframe { page = iframePage } // Default extract type if extractType == "" { extractType = "textContent" } // Find elements var elements rod.Elements var err error if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() elements, err = page.Context(ctx).Elements(selector) } else { elements, err = page.Elements(selector) } if err != nil { return nil, fmt.Errorf("failed to find elements: %w", err) } result := &TextExtractionResult{ Count: len(elements), } // Compile regex pattern if provided var textRegex *regexp.Regexp if pattern != "" { textRegex, err = regexp.Compile(pattern) if err != nil { return nil, fmt.Errorf("invalid text pattern: %w", err) } } // Extract text from all elements var allTexts []string for _, element := range elements { var text string switch extractType { case "text": text, err = element.Text() case "innerText": // Use JavaScript to get innerText jsResult, jsErr := element.Eval("() => this.innerText") if jsErr == nil && jsResult.Value.Str() != "" { text = jsResult.Value.Str() } else { text, err = element.Text() // Fallback } case "textContent": // Use JavaScript to get textContent jsResult, jsErr := element.Eval("() => this.textContent") if jsErr == nil && jsResult.Value.Str() != "" { text = jsResult.Value.Str() } else { text, err = element.Text() // Fallback } default: text, err = element.Text() } if err != nil { continue } allTexts = append(allTexts, text) } // Join all texts result.Text = strings.Join(allTexts, "\n") // Apply pattern matching if provided if textRegex != nil { matches := textRegex.FindAllString(result.Text, -1) result.Matches = matches } return result, nil } // FormField represents a form field with its properties type FormField struct { Name string `json:"name"` Type string `json:"type"` Value string `json:"value"` Placeholder string `json:"placeholder,omitempty"` Required bool `json:"required"` Disabled bool `json:"disabled"` ReadOnly bool `json:"readonly"` Selector string `json:"selector"` Label string `json:"label,omitempty"` Options []FormFieldOption `json:"options,omitempty"` // For select/radio/checkbox } // FormFieldOption represents an option in a select, radio, or checkbox group type FormFieldOption struct { Value string `json:"value"` Text string `json:"text"` Selected bool `json:"selected"` } // FormAnalysisResult represents the result of analyzing a form type FormAnalysisResult struct { Action string `json:"action,omitempty"` Method string `json:"method,omitempty"` Fields []FormField `json:"fields"` FieldCount int `json:"field_count"` CanSubmit bool `json:"can_submit"` SubmitText string `json:"submit_text,omitempty"` } // InteractionItem represents a single interaction to perform type InteractionItem struct { Selector string `json:"selector"` Action string `json:"action"` // click, fill, select, check, uncheck Value string `json:"value,omitempty"` } // InteractionResult represents the result of a single interaction type InteractionResult struct { Selector string `json:"selector"` Action string `json:"action"` Success bool `json:"success"` Error string `json:"error,omitempty"` } // MultipleInteractionResult represents the result of multiple interactions type MultipleInteractionResult struct { Results []InteractionResult `json:"results"` SuccessCount int `json:"success_count"` ErrorCount int `json:"error_count"` TotalCount int `json:"total_count"` } // FormBulkFillResult represents the result of bulk form filling type FormBulkFillResult struct { FilledFields []InteractionResult `json:"filled_fields"` SuccessCount int `json:"success_count"` ErrorCount int `json:"error_count"` TotalCount int `json:"total_count"` } // analyzeForm analyzes a form and returns detailed information about its fields func (d *Daemon) analyzeForm(tabID, selector string, timeout int) (*FormAnalysisResult, error) { page, err := d.getTab(tabID) if err != nil { return nil, err } // Find the form element var form *rod.Element if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() form, err = page.Context(ctx).Element(selector) } else { form, err = page.Element(selector) } if err != nil { return nil, fmt.Errorf("failed to find form: %w", err) } result := &FormAnalysisResult{ Fields: make([]FormField, 0), } // Get form action and method if action, err := form.Attribute("action"); err == nil && action != nil { result.Action = *action } if method, err := form.Attribute("method"); err == nil && method != nil { result.Method = *method } else { result.Method = "GET" // Default } // Find all form fields fieldSelectors := []string{ "input", "textarea", "select", "button[type='submit']", "input[type='submit']", } for _, fieldSelector := range fieldSelectors { elements, err := form.Elements(fieldSelector) if err != nil { continue } for _, element := range elements { field := FormField{} // Get basic attributes if name, err := element.Attribute("name"); err == nil && name != nil { field.Name = *name } if id, err := element.Attribute("id"); err == nil && id != nil && field.Name == "" { field.Name = *id } if fieldType, err := element.Attribute("type"); err == nil && fieldType != nil { field.Type = *fieldType } else { // Get tag name if no type if tagName, err := element.Eval("() => this.tagName.toLowerCase()"); err == nil { field.Type = tagName.Value.Str() } } // Skip submit buttons for field analysis but note them for submission info if field.Type == "submit" { result.CanSubmit = true if value, err := element.Attribute("value"); err == nil && value != nil { result.SubmitText = *value } else if text, err := element.Text(); err == nil { result.SubmitText = text } continue } // Get current value if value, err := element.Attribute("value"); err == nil && value != nil { field.Value = *value } // Get placeholder if placeholder, err := element.Attribute("placeholder"); err == nil && placeholder != nil { field.Placeholder = *placeholder } // Get boolean attributes if required, err := element.Attribute("required"); err == nil && required != nil { field.Required = true } if disabled, err := element.Attribute("disabled"); err == nil && disabled != nil { field.Disabled = true } if readonly, err := element.Attribute("readonly"); err == nil && readonly != nil { field.ReadOnly = true } // Generate selector for this field if field.Name != "" { field.Selector = fmt.Sprintf("[name='%s']", field.Name) } else if id, err := element.Attribute("id"); err == nil && id != nil { field.Selector = fmt.Sprintf("#%s", *id) } // Try to find associated label if field.Name != "" { if label, err := form.Element(fmt.Sprintf("label[for='%s']", field.Name)); err == nil { if labelText, err := label.Text(); err == nil { field.Label = labelText } } } // Handle select options if field.Type == "select" { options, err := element.Elements("option") if err == nil { field.Options = make([]FormFieldOption, 0) for _, option := range options { opt := FormFieldOption{} if value, err := option.Attribute("value"); err == nil && value != nil { opt.Value = *value } if text, err := option.Text(); err == nil { opt.Text = text } if selected, err := option.Attribute("selected"); err == nil && selected != nil { opt.Selected = true } field.Options = append(field.Options, opt) } } } result.Fields = append(result.Fields, field) } } result.FieldCount = len(result.Fields) // Check if form can be submitted (has submit button or can be submitted via JS) if !result.CanSubmit { // Look for any button that might submit if buttons, err := form.Elements("button"); err == nil { for _, button := range buttons { if buttonType, err := button.Attribute("type"); err == nil && buttonType != nil { if *buttonType == "submit" || *buttonType == "" { result.CanSubmit = true if text, err := button.Text(); err == nil { result.SubmitText = text } break } } } } } return result, nil } // interactMultiple performs multiple interactions in sequence func (d *Daemon) interactMultiple(tabID, interactionsJSON string, timeout int) (*MultipleInteractionResult, error) { page, err := d.getTab(tabID) if err != nil { return nil, err } // Parse interactions JSON var interactions []InteractionItem err = json.Unmarshal([]byte(interactionsJSON), &interactions) if err != nil { return nil, fmt.Errorf("failed to parse interactions JSON: %w", err) } result := &MultipleInteractionResult{ Results: make([]InteractionResult, 0), TotalCount: len(interactions), } // Perform each interaction for _, interaction := range interactions { interactionResult := InteractionResult{ Selector: interaction.Selector, Action: interaction.Action, Success: false, } // Find the element without timeout to avoid context cancellation issues var element *rod.Element element, err = page.Element(interaction.Selector) if err != nil { interactionResult.Error = fmt.Sprintf("failed to find element: %v", err) result.Results = append(result.Results, interactionResult) result.ErrorCount++ continue } // Perform the action switch interaction.Action { case "click": err = element.Click(proto.InputMouseButtonLeft, 1) // Retry once if context was canceled if err != nil && strings.Contains(err.Error(), "context canceled") { // Try to find element again and click element, err = page.Element(interaction.Selector) if err == nil { err = element.Click(proto.InputMouseButtonLeft, 1) } } if err != nil { interactionResult.Error = fmt.Sprintf("failed to click: %v", err) } else { interactionResult.Success = true } case "fill": // Clear field first err = element.SelectAllText() if err == nil { err = element.Input("") } if err == nil { err = element.Input(interaction.Value) } // Retry once if context was canceled if err != nil && strings.Contains(err.Error(), "context canceled") { // Try to find element again and fill element, err = page.Element(interaction.Selector) if err == nil { err = element.SelectAllText() if err == nil { err = element.Input("") } if err == nil { err = element.Input(interaction.Value) } } } if err != nil { interactionResult.Error = fmt.Sprintf("failed to fill: %v", err) } else { interactionResult.Success = true } case "select": // For select elements, use rod's built-in Select method // Try to select by text first (most common case) err = element.Select([]string{interaction.Value}, true, rod.SelectorTypeText) if err != nil { // If text selection failed, use JavaScript as fallback // Execute the value assignment (ignore evaluation errors, rod has issues with some JS patterns) script := fmt.Sprintf("document.querySelector(\"%s\").value = \"%s\"", interaction.Selector, interaction.Value) page.Eval(script) // Dispatch the change event separately changeScript := fmt.Sprintf("document.querySelector(\"%s\").dispatchEvent(new Event(\"change\", { bubbles: true }))", interaction.Selector) page.Eval(changeScript) // Verify the selection worked by checking the element's value property directly currentValue, err := element.Property("value") if err != nil { interactionResult.Error = fmt.Sprintf("failed to verify selection: %v", err) } else if currentValue.Str() != interaction.Value { interactionResult.Error = fmt.Sprintf("failed to select option '%s' (current value: %s)", interaction.Value, currentValue.Str()) } else { interactionResult.Success = true } } else { interactionResult.Success = true } case "check": // Check if it's already checked checked, err := element.Property("checked") if err == nil && checked.Bool() { interactionResult.Success = true // Already checked } else { err = element.Click(proto.InputMouseButtonLeft, 1) // Retry once if context was canceled if err != nil && strings.Contains(err.Error(), "context canceled") { // Try to find element again and click element, err = page.Element(interaction.Selector) if err == nil { err = element.Click(proto.InputMouseButtonLeft, 1) } } if err != nil { interactionResult.Error = fmt.Sprintf("failed to check: %v", err) } else { interactionResult.Success = true } } case "uncheck": // Check if it's already unchecked checked, err := element.Property("checked") if err == nil && !checked.Bool() { interactionResult.Success = true // Already unchecked } else { err = element.Click(proto.InputMouseButtonLeft, 1) if err != nil { interactionResult.Error = fmt.Sprintf("failed to uncheck: %v", err) } else { interactionResult.Success = true } } default: interactionResult.Error = fmt.Sprintf("unknown action: %s", interaction.Action) } result.Results = append(result.Results, interactionResult) if interactionResult.Success { result.SuccessCount++ } else { result.ErrorCount++ } } return result, nil } // fillFormBulk fills multiple form fields in a single operation func (d *Daemon) fillFormBulk(tabID, formSelector, fieldsJSON string, timeout int) (*FormBulkFillResult, error) { page, err := d.getTab(tabID) if err != nil { return nil, err } // Parse fields JSON var fields map[string]string err = json.Unmarshal([]byte(fieldsJSON), &fields) if err != nil { return nil, fmt.Errorf("failed to parse fields JSON: %w", err) } result := &FormBulkFillResult{ FilledFields: make([]InteractionResult, 0), TotalCount: len(fields), } // Find the form element if selector is provided var form *rod.Element if formSelector != "" { if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) form, err = page.Context(ctx).Element(formSelector) cancel() } else { form, err = page.Element(formSelector) } if err != nil { return nil, fmt.Errorf("failed to find form: %w", err) } } // Fill each field for fieldName, fieldValue := range fields { fieldResult := InteractionResult{ Selector: fieldName, Action: "fill", // Default action, will be updated based on element type Success: false, } // Try different selector strategies for the field (fast, no individual timeouts) var element *rod.Element selectors := []string{ fmt.Sprintf("[name='%s']", fieldName), fmt.Sprintf("#%s", fieldName), fmt.Sprintf("[id='%s']", fieldName), fieldName, // In case it's already a full selector } // Search for element (try form first if available, then page) for _, selector := range selectors { // Try without timeout first (should be instant if element exists) if form != nil { element, err = form.Element(selector) } else { element, err = page.Element(selector) } if err == nil { fieldResult.Selector = selector break } } if element == nil { fieldResult.Error = fmt.Sprintf("failed to find field: %s", fieldName) result.FilledFields = append(result.FilledFields, fieldResult) result.ErrorCount++ continue } // Determine the element type using rod's built-in method (much faster than Eval) tagName, err := element.Property("tagName") if err != nil { fieldResult.Error = fmt.Sprintf("failed to get element tag name: %v", err) result.FilledFields = append(result.FilledFields, fieldResult) result.ErrorCount++ continue } // Handle different element types if strings.ToLower(tagName.Str()) == "select" { // Use select action for select elements fieldResult.Action = "select" err = element.Select([]string{fieldValue}, true, rod.SelectorTypeText) if err != nil { // If text selection failed, use JavaScript as fallback // Execute the value assignment (ignore evaluation errors, rod has issues with some JS patterns) script := fmt.Sprintf("document.querySelector(\"%s\").value = \"%s\"", fieldResult.Selector, fieldValue) page.Eval(script) // Dispatch the change event separately changeScript := fmt.Sprintf("document.querySelector(\"%s\").dispatchEvent(new Event(\"change\", { bubbles: true }))", fieldResult.Selector) page.Eval(changeScript) // Verify the selection worked by checking the element's value property directly currentValue, err := element.Property("value") if err != nil { fieldResult.Error = fmt.Sprintf("failed to verify selection: %v", err) result.ErrorCount++ } else if currentValue.Str() != fieldValue { fieldResult.Error = fmt.Sprintf("failed to select option '%s' (current value: %s)", fieldValue, currentValue.Str()) result.ErrorCount++ } else { fieldResult.Success = true result.SuccessCount++ } } else { fieldResult.Success = true result.SuccessCount++ } } else { // Use fill action for input, textarea, etc. fieldResult.Action = "fill" err = element.SelectAllText() if err == nil { err = element.Input("") } if err == nil { err = element.Input(fieldValue) } if err != nil { fieldResult.Error = fmt.Sprintf("failed to fill field: %v", err) result.ErrorCount++ } else { fieldResult.Success = true result.SuccessCount++ } } result.FilledFields = append(result.FilledFields, fieldResult) } return result, nil } // PageInfo represents page metadata and state information type PageInfo struct { Title string `json:"title"` URL string `json:"url"` LoadingState string `json:"loading_state"` ReadyState string `json:"ready_state"` Referrer string `json:"referrer"` Domain string `json:"domain"` Protocol string `json:"protocol"` Charset string `json:"charset"` ContentType string `json:"content_type"` LastModified string `json:"last_modified"` CookieEnabled bool `json:"cookie_enabled"` OnlineStatus bool `json:"online_status"` } // ViewportInfo represents viewport and scroll information type ViewportInfo struct { Width int `json:"width"` Height int `json:"height"` ScrollX int `json:"scroll_x"` ScrollY int `json:"scroll_y"` ScrollWidth int `json:"scroll_width"` ScrollHeight int `json:"scroll_height"` ClientWidth int `json:"client_width"` ClientHeight int `json:"client_height"` DevicePixelRatio float64 `json:"device_pixel_ratio"` Orientation string `json:"orientation"` } // PerformanceMetrics represents page performance data type PerformanceMetrics struct { NavigationStart int64 `json:"navigation_start"` LoadEventEnd int64 `json:"load_event_end"` DOMContentLoaded int64 `json:"dom_content_loaded"` FirstPaint int64 `json:"first_paint"` FirstContentfulPaint int64 `json:"first_contentful_paint"` LoadTime int64 `json:"load_time"` DOMLoadTime int64 `json:"dom_load_time"` ResourceCount int `json:"resource_count"` JSHeapSizeLimit int64 `json:"js_heap_size_limit"` JSHeapSizeTotal int64 `json:"js_heap_size_total"` JSHeapSizeUsed int64 `json:"js_heap_size_used"` } // ContentCheck represents content verification results type ContentCheck struct { Type string `json:"type"` ImagesLoaded int `json:"images_loaded,omitempty"` ImagesTotal int `json:"images_total,omitempty"` ScriptsLoaded int `json:"scripts_loaded,omitempty"` ScriptsTotal int `json:"scripts_total,omitempty"` StylesLoaded int `json:"styles_loaded,omitempty"` StylesTotal int `json:"styles_total,omitempty"` FormsPresent int `json:"forms_present,omitempty"` LinksPresent int `json:"links_present,omitempty"` IframesPresent int `json:"iframes_present,omitempty"` HasErrors bool `json:"has_errors,omitempty"` ErrorCount int `json:"error_count,omitempty"` ErrorMessages []string `json:"error_messages,omitempty"` } // getPageInfo retrieves comprehensive page metadata and state information func (d *Daemon) getPageInfo(tabID string, timeout int) (*PageInfo, error) { d.debugLog("Getting page info for tab: %s with timeout: %d", tabID, timeout) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } result := &PageInfo{} // Get basic page information using JavaScript // Note: page.Eval expects a function expression, not an IIFE jsCode := `() => { return { title: document.title, url: window.location.href, readyState: document.readyState, referrer: document.referrer, domain: document.domain, protocol: window.location.protocol, charset: document.characterSet || document.charset, contentType: document.contentType, lastModified: document.lastModified, cookieEnabled: navigator.cookieEnabled, onlineStatus: navigator.onLine }; }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to execute JavaScript: %v", err) } // Parse the JavaScript result if props := jsResult.Value.Map(); props != nil { if title, ok := props["title"]; ok && title.Str() != "" { result.Title = title.Str() } if url, ok := props["url"]; ok && url.Str() != "" { result.URL = url.Str() } if readyState, ok := props["readyState"]; ok && readyState.Str() != "" { result.ReadyState = readyState.Str() } if referrer, ok := props["referrer"]; ok && referrer.Str() != "" { result.Referrer = referrer.Str() } if domain, ok := props["domain"]; ok && domain.Str() != "" { result.Domain = domain.Str() } if protocol, ok := props["protocol"]; ok && protocol.Str() != "" { result.Protocol = protocol.Str() } if charset, ok := props["charset"]; ok && charset.Str() != "" { result.Charset = charset.Str() } if contentType, ok := props["contentType"]; ok && contentType.Str() != "" { result.ContentType = contentType.Str() } if lastModified, ok := props["lastModified"]; ok && lastModified.Str() != "" { result.LastModified = lastModified.Str() } if cookieEnabled, ok := props["cookieEnabled"]; ok { result.CookieEnabled = cookieEnabled.Bool() } if onlineStatus, ok := props["onlineStatus"]; ok { result.OnlineStatus = onlineStatus.Bool() } } // Determine loading state if result.ReadyState == "complete" { result.LoadingState = "complete" } else if result.ReadyState == "interactive" { result.LoadingState = "interactive" } else { result.LoadingState = "loading" } d.debugLog("Successfully retrieved page info for tab: %s", tabID) return result, nil } // getViewportInfo retrieves viewport and scroll information func (d *Daemon) getViewportInfo(tabID string, timeout int) (*ViewportInfo, error) { d.debugLog("Getting viewport info for tab: %s with timeout: %d", tabID, timeout) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } result := &ViewportInfo{} // Get viewport and scroll information using JavaScript // Note: page.Eval expects a function expression, not an IIFE jsCode := `() => { return { width: window.innerWidth, height: window.innerHeight, scrollX: window.scrollX || window.pageXOffset, scrollY: window.scrollY || window.pageYOffset, scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, clientWidth: document.documentElement.clientWidth, clientHeight: document.documentElement.clientHeight, devicePixelRatio: window.devicePixelRatio, orientation: screen.orientation ? screen.orientation.type : 'unknown' }; }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to execute JavaScript: %v", err) } // Parse the JavaScript result if props := jsResult.Value.Map(); props != nil { if width, ok := props["width"]; ok { result.Width = int(width.Num()) } if height, ok := props["height"]; ok { result.Height = int(height.Num()) } if scrollX, ok := props["scrollX"]; ok { result.ScrollX = int(scrollX.Num()) } if scrollY, ok := props["scrollY"]; ok { result.ScrollY = int(scrollY.Num()) } if scrollWidth, ok := props["scrollWidth"]; ok { result.ScrollWidth = int(scrollWidth.Num()) } if scrollHeight, ok := props["scrollHeight"]; ok { result.ScrollHeight = int(scrollHeight.Num()) } if clientWidth, ok := props["clientWidth"]; ok { result.ClientWidth = int(clientWidth.Num()) } if clientHeight, ok := props["clientHeight"]; ok { result.ClientHeight = int(clientHeight.Num()) } if devicePixelRatio, ok := props["devicePixelRatio"]; ok { result.DevicePixelRatio = devicePixelRatio.Num() } if orientation, ok := props["orientation"]; ok && orientation.Str() != "" { result.Orientation = orientation.Str() } } d.debugLog("Successfully retrieved viewport info for tab: %s", tabID) return result, nil } // getPerformance retrieves page performance metrics func (d *Daemon) getPerformance(tabID string, timeout int) (*PerformanceMetrics, error) { d.debugLog("Getting performance metrics for tab: %s with timeout: %d", tabID, timeout) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } result := &PerformanceMetrics{} // Get performance metrics using JavaScript // Note: page.Eval expects a function expression, not an IIFE jsCode := `() => { const perf = window.performance; const timing = perf.timing; const navigation = perf.navigation; const memory = perf.memory; // Get paint metrics if available let firstPaint = 0; let firstContentfulPaint = 0; if (perf.getEntriesByType) { const paintEntries = perf.getEntriesByType('paint'); for (const entry of paintEntries) { if (entry.name === 'first-paint') { firstPaint = entry.startTime; } else if (entry.name === 'first-contentful-paint') { firstContentfulPaint = entry.startTime; } } } // Count resources let resourceCount = 0; if (perf.getEntriesByType) { resourceCount = perf.getEntriesByType('resource').length; } return { navigationStart: timing.navigationStart, loadEventEnd: timing.loadEventEnd, domContentLoaded: timing.domContentLoadedEventEnd, firstPaint: firstPaint, firstContentfulPaint: firstContentfulPaint, loadTime: timing.loadEventEnd - timing.navigationStart, domLoadTime: timing.domContentLoadedEventEnd - timing.navigationStart, resourceCount: resourceCount, jsHeapSizeLimit: memory ? memory.jsHeapSizeLimit : 0, jsHeapSizeTotal: memory ? memory.totalJSHeapSize : 0, jsHeapSizeUsed: memory ? memory.usedJSHeapSize : 0 }; }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to execute JavaScript: %v", err) } // Parse the JavaScript result if props := jsResult.Value.Map(); props != nil { if navigationStart, ok := props["navigationStart"]; ok { result.NavigationStart = int64(navigationStart.Num()) } if loadEventEnd, ok := props["loadEventEnd"]; ok { result.LoadEventEnd = int64(loadEventEnd.Num()) } if domContentLoaded, ok := props["domContentLoaded"]; ok { result.DOMContentLoaded = int64(domContentLoaded.Num()) } if firstPaint, ok := props["firstPaint"]; ok { result.FirstPaint = int64(firstPaint.Num()) } if firstContentfulPaint, ok := props["firstContentfulPaint"]; ok { result.FirstContentfulPaint = int64(firstContentfulPaint.Num()) } if loadTime, ok := props["loadTime"]; ok { result.LoadTime = int64(loadTime.Num()) } if domLoadTime, ok := props["domLoadTime"]; ok { result.DOMLoadTime = int64(domLoadTime.Num()) } if resourceCount, ok := props["resourceCount"]; ok { result.ResourceCount = int(resourceCount.Num()) } if jsHeapSizeLimit, ok := props["jsHeapSizeLimit"]; ok { result.JSHeapSizeLimit = int64(jsHeapSizeLimit.Num()) } if jsHeapSizeTotal, ok := props["jsHeapSizeTotal"]; ok { result.JSHeapSizeTotal = int64(jsHeapSizeTotal.Num()) } if jsHeapSizeUsed, ok := props["jsHeapSizeUsed"]; ok { result.JSHeapSizeUsed = int64(jsHeapSizeUsed.Num()) } } d.debugLog("Successfully retrieved performance metrics for tab: %s", tabID) return result, nil } // checkContent verifies specific content types and loading states func (d *Daemon) checkContent(tabID string, contentType string, timeout int) (*ContentCheck, error) { d.debugLog("Checking content type '%s' for tab: %s with timeout: %d", contentType, tabID, timeout) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } result := &ContentCheck{ Type: contentType, } var jsCode string switch contentType { case "images": // Note: page.Eval expects a function expression, not an IIFE jsCode = `() => { const images = document.querySelectorAll('img'); let loaded = 0; let total = images.length; images.forEach(img => { if (img.complete && img.naturalHeight !== 0) { loaded++; } }); return { imagesLoaded: loaded, imagesTotal: total }; }` case "scripts": jsCode = `() => { const scripts = document.querySelectorAll('script[src]'); let loaded = 0; let total = scripts.length; scripts.forEach(script => { if (script.readyState === 'loaded' || script.readyState === 'complete' || !script.readyState) { loaded++; } }); return { scriptsLoaded: loaded, scriptsTotal: total }; }` case "styles": jsCode = `() => { const styles = document.querySelectorAll('link[rel="stylesheet"]'); let loaded = 0; let total = styles.length; styles.forEach(style => { if (style.sheet) { loaded++; } }); return { stylesLoaded: loaded, stylesTotal: total }; }` case "forms": jsCode = `() => { return { formsPresent: document.querySelectorAll('form').length }; }` case "links": jsCode = `() => { return { linksPresent: document.querySelectorAll('a[href]').length }; }` case "iframes": jsCode = `() => { return { iframesPresent: document.querySelectorAll('iframe').length }; }` case "errors": jsCode = `() => { const errors = []; // Check for JavaScript errors in console (if available) if (window.console && window.console.error) { // This is limited - we can't access console history // But we can check for common error indicators } // Check for broken images const brokenImages = Array.from(document.querySelectorAll('img')).filter(img => !img.complete || img.naturalHeight === 0 ); if (brokenImages.length > 0) { errors.push('Broken images detected: ' + brokenImages.length); } // Check for missing stylesheets const brokenStyles = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter(link => !link.sheet ); if (brokenStyles.length > 0) { errors.push('Missing stylesheets detected: ' + brokenStyles.length); } return { hasErrors: errors.length > 0, errorCount: errors.length, errorMessages: errors }; }` default: return nil, fmt.Errorf("unknown content type: %s", contentType) } jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to execute JavaScript: %v", err) } // Parse the JavaScript result if props := jsResult.Value.Map(); props != nil { if imagesLoaded, ok := props["imagesLoaded"]; ok { result.ImagesLoaded = int(imagesLoaded.Num()) } if imagesTotal, ok := props["imagesTotal"]; ok { result.ImagesTotal = int(imagesTotal.Num()) } if scriptsLoaded, ok := props["scriptsLoaded"]; ok { result.ScriptsLoaded = int(scriptsLoaded.Num()) } if scriptsTotal, ok := props["scriptsTotal"]; ok { result.ScriptsTotal = int(scriptsTotal.Num()) } if stylesLoaded, ok := props["stylesLoaded"]; ok { result.StylesLoaded = int(stylesLoaded.Num()) } if stylesTotal, ok := props["stylesTotal"]; ok { result.StylesTotal = int(stylesTotal.Num()) } if formsPresent, ok := props["formsPresent"]; ok { result.FormsPresent = int(formsPresent.Num()) } if linksPresent, ok := props["linksPresent"]; ok { result.LinksPresent = int(linksPresent.Num()) } if iframesPresent, ok := props["iframesPresent"]; ok { result.IframesPresent = int(iframesPresent.Num()) } if hasErrors, ok := props["hasErrors"]; ok { result.HasErrors = hasErrors.Bool() } if errorCount, ok := props["errorCount"]; ok { result.ErrorCount = int(errorCount.Num()) } if errorMessages, ok := props["errorMessages"]; ok { if arr := errorMessages.Arr(); arr != nil { for _, msg := range arr { if msg.Str() != "" { result.ErrorMessages = append(result.ErrorMessages, msg.Str()) } } } } } d.debugLog("Successfully checked content type '%s' for tab: %s", contentType, tabID) return result, nil } // screenshotElement takes a screenshot of a specific element func (d *Daemon) screenshotElement(tabID, selector, outputPath string, timeout int) error { d.debugLog("Taking element screenshot for tab: %s, selector: %s", tabID, selector) page, err := d.getTab(tabID) if err != nil { return err } // Find the element var element *rod.Element if timeout > 0 { element, err = page.Timeout(time.Duration(timeout) * time.Second).Element(selector) if err != nil { return fmt.Errorf("failed to find element (timeout after %ds): %w", timeout, err) } } else { element, err = page.Element(selector) if err != nil { return fmt.Errorf("failed to find element: %w", err) } } // Scroll element into view err = element.ScrollIntoView() if err != nil { return fmt.Errorf("failed to scroll element into view: %w", err) } // Wait for element to be stable err = element.WaitStable(500 * time.Millisecond) if err != nil { d.debugLog("Warning: element not stable: %v", err) } // Take screenshot of the element screenshotBytes, err := element.Screenshot(proto.PageCaptureScreenshotFormatPng, 0) if err != nil { return fmt.Errorf("failed to capture element screenshot: %w", err) } // Write the screenshot to file err = os.WriteFile(outputPath, screenshotBytes, 0644) if err != nil { return fmt.Errorf("failed to save element screenshot to %s: %w", outputPath, err) } d.debugLog("Successfully captured element screenshot for tab: %s", tabID) return nil } // ScreenshotMetadata represents metadata for enhanced screenshots type ScreenshotMetadata struct { Timestamp string `json:"timestamp"` URL string `json:"url"` Title string `json:"title"` ViewportSize struct { Width int `json:"width"` Height int `json:"height"` } `json:"viewport_size"` FullPage bool `json:"full_page"` FilePath string `json:"file_path"` FileSize int64 `json:"file_size"` Resolution struct { Width int `json:"width"` Height int `json:"height"` } `json:"resolution"` } // screenshotEnhanced takes a screenshot with metadata func (d *Daemon) screenshotEnhanced(tabID, outputPath string, fullPage bool, timeout int) (*ScreenshotMetadata, error) { d.debugLog("Taking enhanced screenshot for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, err } // Get page info for metadata pageInfo, err := page.Info() if err != nil { return nil, fmt.Errorf("failed to get page info: %w", err) } // Get viewport size viewport, err := page.Eval(`() => ({ width: window.innerWidth, height: window.innerHeight })`) if err != nil { return nil, fmt.Errorf("failed to get viewport: %w", err) } viewportData := viewport.Value.Map() viewportWidth := int(viewportData["width"].Num()) viewportHeight := int(viewportData["height"].Num()) // Take screenshot with timeout handling var screenshotBytes []byte if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() done := make(chan error, 1) go func() { bytes, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ Format: proto.PageCaptureScreenshotFormatPng, }) screenshotBytes = bytes done <- err }() select { case err := <-done: if err != nil { return nil, fmt.Errorf("failed to capture screenshot: %w", err) } case <-ctx.Done(): return nil, fmt.Errorf("taking screenshot timed out after %d seconds", timeout) } } else { screenshotBytes, err = page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ Format: proto.PageCaptureScreenshotFormatPng, }) if err != nil { return nil, fmt.Errorf("failed to capture screenshot: %w", err) } } // Write the screenshot to file err = os.WriteFile(outputPath, screenshotBytes, 0644) if err != nil { return nil, fmt.Errorf("failed to save screenshot to %s: %w", outputPath, err) } // Get file info fileInfo, err := os.Stat(outputPath) if err != nil { return nil, fmt.Errorf("failed to get file info: %w", err) } // Create metadata metadata := &ScreenshotMetadata{ Timestamp: time.Now().Format(time.RFC3339), URL: pageInfo.URL, Title: pageInfo.Title, FullPage: fullPage, FilePath: outputPath, FileSize: fileInfo.Size(), } metadata.ViewportSize.Width = viewportWidth metadata.ViewportSize.Height = viewportHeight // Get actual image dimensions (approximate based on viewport or full page) if fullPage { // For full page, we'd need to calculate the full document size // For now, use viewport size as approximation metadata.Resolution.Width = viewportWidth metadata.Resolution.Height = viewportHeight } else { metadata.Resolution.Width = viewportWidth metadata.Resolution.Height = viewportHeight } d.debugLog("Successfully captured enhanced screenshot for tab: %s", tabID) return metadata, nil } // FileOperation represents a single file operation type FileOperation struct { LocalPath string `json:"local_path"` ContainerPath string `json:"container_path"` Operation string `json:"operation"` // "upload" or "download" } // BulkFileResult represents the result of bulk file operations type BulkFileResult struct { Successful []FileOperationResult `json:"successful"` Failed []FileOperationError `json:"failed"` Summary struct { Total int `json:"total"` Successful int `json:"successful"` Failed int `json:"failed"` } `json:"summary"` } // FileOperationResult represents a successful file operation type FileOperationResult struct { LocalPath string `json:"local_path"` ContainerPath string `json:"container_path"` Operation string `json:"operation"` Size int64 `json:"size"` } // FileOperationError represents a failed file operation type FileOperationError struct { LocalPath string `json:"local_path"` ContainerPath string `json:"container_path"` Operation string `json:"operation"` Error string `json:"error"` } // bulkFiles performs bulk file operations (upload/download) func (d *Daemon) bulkFiles(operationType, filesJSON string, timeout int) (*BulkFileResult, error) { d.debugLog("Performing bulk file operations: %s", operationType) // Parse the files JSON var operations []FileOperation err := json.Unmarshal([]byte(filesJSON), &operations) if err != nil { return nil, fmt.Errorf("failed to parse files JSON: %w", err) } result := &BulkFileResult{ Successful: make([]FileOperationResult, 0), Failed: make([]FileOperationError, 0), } // Set up timeout context ctx := context.Background() if timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second) defer cancel() } // Process each file operation for _, op := range operations { select { case <-ctx.Done(): // Timeout reached, add remaining operations as failed for i := len(result.Successful) + len(result.Failed); i < len(operations); i++ { result.Failed = append(result.Failed, FileOperationError{ LocalPath: operations[i].LocalPath, ContainerPath: operations[i].ContainerPath, Operation: operations[i].Operation, Error: "operation timed out", }) } break default: // Perform the operation if op.Operation == "upload" || (op.Operation == "" && operationType == "upload") { err := d.performFileUpload(op.LocalPath, op.ContainerPath) if err != nil { result.Failed = append(result.Failed, FileOperationError{ LocalPath: op.LocalPath, ContainerPath: op.ContainerPath, Operation: "upload", Error: err.Error(), }) } else { // Get file size fileInfo, _ := os.Stat(op.ContainerPath) size := int64(0) if fileInfo != nil { size = fileInfo.Size() } result.Successful = append(result.Successful, FileOperationResult{ LocalPath: op.LocalPath, ContainerPath: op.ContainerPath, Operation: "upload", Size: size, }) } } else if op.Operation == "download" || (op.Operation == "" && operationType == "download") { err := d.performFileDownload(op.ContainerPath, op.LocalPath) if err != nil { result.Failed = append(result.Failed, FileOperationError{ LocalPath: op.LocalPath, ContainerPath: op.ContainerPath, Operation: "download", Error: err.Error(), }) } else { // Get file size fileInfo, _ := os.Stat(op.LocalPath) size := int64(0) if fileInfo != nil { size = fileInfo.Size() } result.Successful = append(result.Successful, FileOperationResult{ LocalPath: op.LocalPath, ContainerPath: op.ContainerPath, Operation: "download", Size: size, }) } } else { result.Failed = append(result.Failed, FileOperationError{ LocalPath: op.LocalPath, ContainerPath: op.ContainerPath, Operation: op.Operation, Error: "unknown operation type", }) } } } // Update summary result.Summary.Total = len(operations) result.Summary.Successful = len(result.Successful) result.Summary.Failed = len(result.Failed) d.debugLog("Bulk file operations completed: %d successful, %d failed", result.Summary.Successful, result.Summary.Failed) return result, nil } // performFileUpload handles a single file upload operation func (d *Daemon) performFileUpload(localPath, containerPath string) error { // Open the source file sourceFile, err := os.Open(localPath) if err != nil { return fmt.Errorf("failed to open source file: %w", err) } defer sourceFile.Close() // Create the destination file destFile, err := os.Create(containerPath) if err != nil { return fmt.Errorf("failed to create destination file: %w", err) } defer destFile.Close() // Copy the file _, err = io.Copy(destFile, sourceFile) if err != nil { return fmt.Errorf("failed to copy file: %w", err) } return nil } // performFileDownload handles a single file download operation func (d *Daemon) performFileDownload(containerPath, localPath string) error { // Open the source file sourceFile, err := os.Open(containerPath) if err != nil { return fmt.Errorf("failed to open source file: %w", err) } defer sourceFile.Close() // Create the destination file destFile, err := os.Create(localPath) if err != nil { return fmt.Errorf("failed to create destination file: %w", err) } defer destFile.Close() // Copy the file _, err = io.Copy(destFile, sourceFile) if err != nil { return fmt.Errorf("failed to copy file: %w", err) } return nil } // FileManagementResult represents the result of file management operations type FileManagementResult struct { Operation string `json:"operation"` Files []FileInfo `json:"files,omitempty"` Cleaned []string `json:"cleaned,omitempty"` Summary map[string]interface{} `json:"summary"` } // FileInfo represents information about a file type FileInfo struct { Path string `json:"path"` Size int64 `json:"size"` ModTime time.Time `json:"mod_time"` IsDir bool `json:"is_dir"` Permissions string `json:"permissions"` } // manageFiles performs file management operations func (d *Daemon) manageFiles(operation, pattern, maxAge string) (*FileManagementResult, error) { d.debugLog("Performing file management operation: %s", operation) result := &FileManagementResult{ Operation: operation, Summary: make(map[string]interface{}), } switch operation { case "cleanup": return d.cleanupFiles(pattern, maxAge, result) case "list": return d.listFiles(pattern, result) case "info": return d.getFileInfo(pattern, result) default: return nil, fmt.Errorf("unknown file management operation: %s", operation) } } // cleanupFiles removes files matching pattern and age criteria func (d *Daemon) cleanupFiles(pattern, maxAge string, result *FileManagementResult) (*FileManagementResult, error) { // Parse max age (default to 24 hours if not specified) maxAgeHours := 24 if maxAge != "" { if parsed, err := strconv.Atoi(maxAge); err == nil && parsed > 0 { maxAgeHours = parsed } } cutoffTime := time.Now().Add(-time.Duration(maxAgeHours) * time.Hour) // Default pattern if not specified if pattern == "" { pattern = "/tmp/cremote-*" } // Find files matching pattern matches, err := filepath.Glob(pattern) if err != nil { return nil, fmt.Errorf("failed to find files matching pattern: %w", err) } var cleaned []string var totalSize int64 for _, filePath := range matches { fileInfo, err := os.Stat(filePath) if err != nil { continue // Skip files we can't stat } // Check if file is older than cutoff time if fileInfo.ModTime().Before(cutoffTime) { totalSize += fileInfo.Size() err = os.Remove(filePath) if err != nil { d.debugLog("Failed to remove file %s: %v", filePath, err) } else { cleaned = append(cleaned, filePath) } } } result.Cleaned = cleaned result.Summary["files_cleaned"] = len(cleaned) result.Summary["total_size_freed"] = totalSize result.Summary["cutoff_time"] = cutoffTime.Format(time.RFC3339) d.debugLog("Cleanup completed: %d files removed, %d bytes freed", len(cleaned), totalSize) return result, nil } // listFiles lists files matching pattern func (d *Daemon) listFiles(pattern string, result *FileManagementResult) (*FileManagementResult, error) { // Default pattern if not specified if pattern == "" { pattern = "/tmp/*" } // Find files matching pattern matches, err := filepath.Glob(pattern) if err != nil { return nil, fmt.Errorf("failed to find files matching pattern: %w", err) } var files []FileInfo var totalSize int64 for _, filePath := range matches { fileInfo, err := os.Stat(filePath) if err != nil { continue // Skip files we can't stat } files = append(files, FileInfo{ Path: filePath, Size: fileInfo.Size(), ModTime: fileInfo.ModTime(), IsDir: fileInfo.IsDir(), Permissions: fileInfo.Mode().String(), }) if !fileInfo.IsDir() { totalSize += fileInfo.Size() } } result.Files = files result.Summary["total_files"] = len(files) result.Summary["total_size"] = totalSize d.debugLog("Listed %d files matching pattern: %s", len(files), pattern) return result, nil } // getFileInfo gets detailed information about a specific file func (d *Daemon) getFileInfo(filePath string, result *FileManagementResult) (*FileManagementResult, error) { if filePath == "" { return nil, fmt.Errorf("file path is required for info operation") } fileInfo, err := os.Stat(filePath) if err != nil { return nil, fmt.Errorf("failed to get file info: %w", err) } files := []FileInfo{{ Path: filePath, Size: fileInfo.Size(), ModTime: fileInfo.ModTime(), IsDir: fileInfo.IsDir(), Permissions: fileInfo.Mode().String(), }} result.Files = files result.Summary["exists"] = true result.Summary["size"] = fileInfo.Size() result.Summary["is_directory"] = fileInfo.IsDir() result.Summary["last_modified"] = fileInfo.ModTime().Format(time.RFC3339) d.debugLog("Retrieved info for file: %s", filePath) return result, nil } // Accessibility tree data structures // AXNode represents a node in the accessibility tree type AXNode struct { NodeID string `json:"nodeId"` Ignored bool `json:"ignored"` IgnoredReasons []AXProperty `json:"ignoredReasons,omitempty"` Role *AXValue `json:"role,omitempty"` ChromeRole *AXValue `json:"chromeRole,omitempty"` Name *AXValue `json:"name,omitempty"` Description *AXValue `json:"description,omitempty"` Value *AXValue `json:"value,omitempty"` Properties []AXProperty `json:"properties,omitempty"` ParentID string `json:"parentId,omitempty"` ChildIDs []string `json:"childIds,omitempty"` BackendDOMNodeID int `json:"backendDOMNodeId,omitempty"` FrameID string `json:"frameId,omitempty"` } // AXProperty represents a property of an accessibility node type AXProperty struct { Name string `json:"name"` Value *AXValue `json:"value"` } // AXValue represents a computed accessibility value type AXValue struct { Type string `json:"type"` Value interface{} `json:"value,omitempty"` RelatedNodes []AXRelatedNode `json:"relatedNodes,omitempty"` Sources []AXValueSource `json:"sources,omitempty"` } // AXRelatedNode represents a related node in the accessibility tree type AXRelatedNode struct { BackendDOMNodeID int `json:"backendDOMNodeId"` IDRef string `json:"idref,omitempty"` Text string `json:"text,omitempty"` } // AXValueSource represents a source for a computed accessibility value type AXValueSource struct { Type string `json:"type"` Value *AXValue `json:"value,omitempty"` Attribute string `json:"attribute,omitempty"` AttributeValue *AXValue `json:"attributeValue,omitempty"` Superseded bool `json:"superseded,omitempty"` NativeSource string `json:"nativeSource,omitempty"` NativeSourceValue *AXValue `json:"nativeSourceValue,omitempty"` Invalid bool `json:"invalid,omitempty"` InvalidReason string `json:"invalidReason,omitempty"` } // AccessibilityTreeResult represents the result of accessibility tree operations type AccessibilityTreeResult struct { Nodes []AXNode `json:"nodes"` } // AccessibilityQueryResult represents the result of accessibility queries type AccessibilityQueryResult struct { Nodes []AXNode `json:"nodes"` } // getAccessibilityTree retrieves the full accessibility tree for a tab func (d *Daemon) getAccessibilityTree(tabID string, depth *int, timeout int) (*AccessibilityTreeResult, error) { return d.getAccessibilityTreeWithContrast(tabID, depth, false, timeout) } // getAccessibilityTreeWithContrast retrieves the full accessibility tree with optional contrast data func (d *Daemon) getAccessibilityTreeWithContrast(tabID string, depth *int, includeContrast bool, timeout int) (*AccessibilityTreeResult, error) { d.debugLog("Getting accessibility tree for tab: %s with depth: %v, includeContrast: %v, timeout: %d", tabID, depth, includeContrast, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Enable accessibility domain err = proto.AccessibilityEnable{}.Call(page) if err != nil { return nil, fmt.Errorf("failed to enable accessibility domain: %v", err) } // Build the request parameters params := proto.AccessibilityGetFullAXTree{} if depth != nil { params.Depth = depth } // Call the Chrome DevTools Protocol Accessibility.getFullAXTree method result, err := proto.AccessibilityGetFullAXTree{}.Call(page) if err != nil { return nil, fmt.Errorf("failed to get accessibility tree: %v", err) } // Parse the result var axResult AccessibilityTreeResult for _, node := range result.Nodes { axNode := d.convertProtoAXNode(node) // Add contrast data if requested (simplified - just add a note that contrast checking is available) if includeContrast && !node.Ignored && node.BackendDOMNodeID > 0 { // Add a property indicating contrast data is available via web_contrast_check tool axNode.Properties = append(axNode.Properties, AXProperty{ Name: "contrastCheckAvailable", Value: &AXValue{ Type: "boolean", Value: true, }, }) } axResult.Nodes = append(axResult.Nodes, axNode) } d.debugLog("Successfully retrieved accessibility tree with %d nodes for tab: %s", len(axResult.Nodes), tabID) return &axResult, nil } // convertProtoAXNode converts a proto.AccessibilityAXNode to our AXNode struct func (d *Daemon) convertProtoAXNode(protoNode *proto.AccessibilityAXNode) AXNode { node := AXNode{ NodeID: string(protoNode.NodeID), Ignored: protoNode.Ignored, BackendDOMNodeID: int(protoNode.BackendDOMNodeID), } // Convert role if protoNode.Role != nil { node.Role = d.convertProtoAXValue(protoNode.Role) } // Convert chrome role if protoNode.ChromeRole != nil { node.ChromeRole = d.convertProtoAXValue(protoNode.ChromeRole) } // Convert name if protoNode.Name != nil { node.Name = d.convertProtoAXValue(protoNode.Name) } // Convert description if protoNode.Description != nil { node.Description = d.convertProtoAXValue(protoNode.Description) } // Convert value if protoNode.Value != nil { node.Value = d.convertProtoAXValue(protoNode.Value) } // Convert properties for _, prop := range protoNode.Properties { node.Properties = append(node.Properties, AXProperty{ Name: string(prop.Name), Value: d.convertProtoAXValue(prop.Value), }) } // Convert ignored reasons for _, reason := range protoNode.IgnoredReasons { node.IgnoredReasons = append(node.IgnoredReasons, AXProperty{ Name: string(reason.Name), Value: d.convertProtoAXValue(reason.Value), }) } // Convert parent and child IDs if protoNode.ParentID != "" { node.ParentID = string(protoNode.ParentID) } for _, childID := range protoNode.ChildIDs { node.ChildIDs = append(node.ChildIDs, string(childID)) } if protoNode.FrameID != "" { node.FrameID = string(protoNode.FrameID) } return node } // convertProtoAXValue converts a proto.AccessibilityAXValue to our AXValue struct func (d *Daemon) convertProtoAXValue(protoValue *proto.AccessibilityAXValue) *AXValue { if protoValue == nil { return nil } value := &AXValue{ Type: string(protoValue.Type), Value: protoValue.Value, } // Convert related nodes for _, relatedNode := range protoValue.RelatedNodes { value.RelatedNodes = append(value.RelatedNodes, AXRelatedNode{ BackendDOMNodeID: int(relatedNode.BackendDOMNodeID), IDRef: relatedNode.Idref, Text: relatedNode.Text, }) } // Convert sources for _, source := range protoValue.Sources { axSource := AXValueSource{ Type: string(source.Type), Superseded: source.Superseded, Invalid: source.Invalid, InvalidReason: source.InvalidReason, } if source.Value != nil { axSource.Value = d.convertProtoAXValue(source.Value) } if source.Attribute != "" { axSource.Attribute = source.Attribute } if source.AttributeValue != nil { axSource.AttributeValue = d.convertProtoAXValue(source.AttributeValue) } if source.NativeSource != "" { axSource.NativeSource = string(source.NativeSource) } if source.NativeSourceValue != nil { axSource.NativeSourceValue = d.convertProtoAXValue(source.NativeSourceValue) } value.Sources = append(value.Sources, axSource) } return value } // getPartialAccessibilityTree retrieves a partial accessibility tree for a specific element func (d *Daemon) getPartialAccessibilityTree(tabID, selector string, fetchRelatives bool, timeout int) (*AccessibilityTreeResult, error) { d.debugLog("Getting partial accessibility tree for tab: %s, selector: %s, fetchRelatives: %v, timeout: %d", tabID, selector, fetchRelatives, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Enable accessibility domain err = proto.AccessibilityEnable{}.Call(page) if err != nil { return nil, fmt.Errorf("failed to enable accessibility domain: %v", err) } // Find the DOM node first var element *rod.Element if timeout > 0 { element, err = page.Timeout(time.Duration(timeout) * time.Second).Element(selector) } else { element, err = page.Element(selector) } if err != nil { return nil, fmt.Errorf("failed to find element: %w", err) } // Get the backend node ID nodeInfo, err := element.Describe(1, false) if err != nil { return nil, fmt.Errorf("failed to describe element: %w", err) } // Call the Chrome DevTools Protocol Accessibility.getPartialAXTree method result, err := proto.AccessibilityGetPartialAXTree{ BackendNodeID: nodeInfo.BackendNodeID, FetchRelatives: fetchRelatives, }.Call(page) if err != nil { return nil, fmt.Errorf("failed to get partial accessibility tree: %v", err) } // Parse the result var axResult AccessibilityTreeResult for _, node := range result.Nodes { axNode := d.convertProtoAXNode(node) axResult.Nodes = append(axResult.Nodes, axNode) } d.debugLog("Successfully retrieved partial accessibility tree with %d nodes for tab: %s", len(axResult.Nodes), tabID) return &axResult, nil } // queryAccessibilityTree queries the accessibility tree for nodes matching specific criteria func (d *Daemon) queryAccessibilityTree(tabID, selector, accessibleName, role string, timeout int) (*AccessibilityQueryResult, error) { d.debugLog("Querying accessibility tree for tab: %s, selector: %s, name: %s, role: %s, timeout: %d", tabID, selector, accessibleName, role, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return nil, fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Enable accessibility domain err = proto.AccessibilityEnable{}.Call(page) if err != nil { return nil, fmt.Errorf("failed to enable accessibility domain: %v", err) } // Find the DOM node first if selector is provided var backendNodeID *proto.DOMBackendNodeID if selector != "" { var element *rod.Element if timeout > 0 { element, err = page.Timeout(time.Duration(timeout) * time.Second).Element(selector) } else { element, err = page.Element(selector) } if err != nil { return nil, fmt.Errorf("failed to find element: %w", err) } // Get the backend node ID nodeInfo, err := element.Describe(1, false) if err != nil { return nil, fmt.Errorf("failed to describe element: %w", err) } backendNodeID = &nodeInfo.BackendNodeID } // Build query parameters queryParams := proto.AccessibilityQueryAXTree{} if backendNodeID != nil { queryParams.BackendNodeID = *backendNodeID } if accessibleName != "" { queryParams.AccessibleName = accessibleName } if role != "" { queryParams.Role = role } // Call the Chrome DevTools Protocol Accessibility.queryAXTree method result, err := queryParams.Call(page) if err != nil { return nil, fmt.Errorf("failed to query accessibility tree: %v", err) } // Parse the result var axResult AccessibilityQueryResult for _, node := range result.Nodes { axNode := d.convertProtoAXNode(node) axResult.Nodes = append(axResult.Nodes, axNode) } d.debugLog("Successfully queried accessibility tree with %d matching nodes for tab: %s", len(axResult.Nodes), tabID) return &axResult, nil } // setCacheDisabled enables or disables browser cache for a tab func (d *Daemon) setCacheDisabled(tabID string, disabled bool, timeout int) error { d.debugLog("Setting cache disabled=%v for tab: %s with timeout: %d", disabled, tabID, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Create a context with timeout if specified if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the cache setting in a goroutine go func() { err := proto.NetworkSetCacheDisabled{CacheDisabled: disabled}.Call(page) done <- err }() // Wait for completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to set cache disabled: %v", err) } case <-ctx.Done(): return fmt.Errorf("timeout setting cache disabled after %d seconds", timeout) } } else { // No timeout - execute directly err := proto.NetworkSetCacheDisabled{CacheDisabled: disabled}.Call(page) if err != nil { return fmt.Errorf("failed to set cache disabled: %v", err) } } d.debugLog("Successfully set cache disabled=%v for tab: %s", disabled, tabID) return nil } // clearBrowserCache clears the browser cache for a tab func (d *Daemon) clearBrowserCache(tabID string, timeout int) error { d.debugLog("Clearing browser cache for tab: %s with timeout: %d", tabID, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Create a context with timeout if specified if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the cache clearing in a goroutine go func() { err := proto.NetworkClearBrowserCache{}.Call(page) done <- err }() // Wait for completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to clear browser cache: %v", err) } case <-ctx.Done(): return fmt.Errorf("timeout clearing browser cache after %d seconds", timeout) } } else { // No timeout - execute directly err := proto.NetworkClearBrowserCache{}.Call(page) if err != nil { return fmt.Errorf("failed to clear browser cache: %v", err) } } d.debugLog("Successfully cleared browser cache for tab: %s", tabID) return nil } // clearAllSiteData clears all site data including cookies, storage, cache, etc. for a tab func (d *Daemon) clearAllSiteData(tabID string, timeout int) error { d.debugLog("Clearing all site data for tab: %s with timeout: %d", tabID, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Create a context with timeout if specified if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the site data clearing in a goroutine go func() { // Clear all types of site data err := proto.StorageClearDataForOrigin{ Origin: "*", // Clear for all origins StorageTypes: "appcache,cookies,file_systems,indexeddb,local_storage,shader_cache,websql,service_workers,cache_storage", }.Call(page) done <- err }() // Wait for completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to clear all site data: %v", err) } case <-ctx.Done(): return fmt.Errorf("timeout clearing all site data after %d seconds", timeout) } } else { // No timeout - execute directly err := proto.StorageClearDataForOrigin{ Origin: "*", // Clear for all origins StorageTypes: "appcache,cookies,file_systems,indexeddb,local_storage,shader_cache,websql,service_workers,cache_storage", }.Call(page) if err != nil { return fmt.Errorf("failed to clear all site data: %v", err) } } d.debugLog("Successfully cleared all site data for tab: %s", tabID) return nil } // clearCookies clears cookies for a tab func (d *Daemon) clearCookies(tabID string, timeout int) error { d.debugLog("Clearing cookies for tab: %s with timeout: %d", tabID, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Create a context with timeout if specified if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the cookie clearing in a goroutine go func() { // Clear cookies only err := proto.StorageClearDataForOrigin{ Origin: "*", // Clear for all origins StorageTypes: "cookies", }.Call(page) done <- err }() // Wait for completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to clear cookies: %v", err) } case <-ctx.Done(): return fmt.Errorf("timeout clearing cookies after %d seconds", timeout) } } else { // No timeout - execute directly err := proto.StorageClearDataForOrigin{ Origin: "*", // Clear for all origins StorageTypes: "cookies", }.Call(page) if err != nil { return fmt.Errorf("failed to clear cookies: %v", err) } } d.debugLog("Successfully cleared cookies for tab: %s", tabID) return nil } // clearStorage clears web storage (localStorage, sessionStorage, IndexedDB, etc.) for a tab func (d *Daemon) clearStorage(tabID string, timeout int) error { d.debugLog("Clearing storage for tab: %s with timeout: %d", tabID, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Create a context with timeout if specified if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the storage clearing in a goroutine go func() { // Clear storage types (excluding cookies and cache) err := proto.StorageClearDataForOrigin{ Origin: "*", // Clear for all origins StorageTypes: "appcache,file_systems,indexeddb,local_storage,websql,service_workers,cache_storage", }.Call(page) done <- err }() // Wait for completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to clear storage: %v", err) } case <-ctx.Done(): return fmt.Errorf("timeout clearing storage after %d seconds", timeout) } } else { // No timeout - execute directly err := proto.StorageClearDataForOrigin{ Origin: "*", // Clear for all origins StorageTypes: "appcache,file_systems,indexeddb,local_storage,websql,service_workers,cache_storage", }.Call(page) if err != nil { return fmt.Errorf("failed to clear storage: %v", err) } } d.debugLog("Successfully cleared storage for tab: %s", tabID) return nil } // dragAndDrop performs a drag and drop operation from source element to target element func (d *Daemon) dragAndDrop(tabID, sourceSelector, targetSelector string, timeout int) error { d.debugLog("Performing drag and drop from %s to %s for tab: %s with timeout: %d", sourceSelector, targetSelector, tabID, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Create a context with timeout if specified if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the drag and drop in a goroutine go func() { err := d.performDragAndDrop(page, sourceSelector, targetSelector) done <- err }() // Wait for completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to perform drag and drop: %v", err) } case <-ctx.Done(): return fmt.Errorf("timeout performing drag and drop after %d seconds", timeout) } } else { // No timeout - execute directly err := d.performDragAndDrop(page, sourceSelector, targetSelector) if err != nil { return fmt.Errorf("failed to perform drag and drop: %v", err) } } d.debugLog("Successfully performed drag and drop for tab: %s", tabID) return nil } // dragAndDropToCoordinates performs a drag and drop operation from source element to specific coordinates func (d *Daemon) dragAndDropToCoordinates(tabID, sourceSelector string, targetX, targetY, timeout int) error { d.debugLog("Performing drag and drop from %s to coordinates (%d, %d) for tab: %s with timeout: %d", sourceSelector, targetX, targetY, tabID, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Create a context with timeout if specified if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the drag and drop in a goroutine go func() { err := d.performDragAndDropToCoordinates(page, sourceSelector, targetX, targetY) done <- err }() // Wait for completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to perform drag and drop to coordinates: %v", err) } case <-ctx.Done(): return fmt.Errorf("timeout performing drag and drop to coordinates after %d seconds", timeout) } } else { // No timeout - execute directly err := d.performDragAndDropToCoordinates(page, sourceSelector, targetX, targetY) if err != nil { return fmt.Errorf("failed to perform drag and drop to coordinates: %v", err) } } d.debugLog("Successfully performed drag and drop to coordinates for tab: %s", tabID) return nil } // dragAndDropByOffset performs a drag and drop operation from source element by a relative offset func (d *Daemon) dragAndDropByOffset(tabID, sourceSelector string, offsetX, offsetY, timeout int) error { d.debugLog("Performing drag and drop from %s by offset (%d, %d) for tab: %s with timeout: %d", sourceSelector, offsetX, offsetY, tabID, timeout) // Use current tab if not specified if tabID == "" { tabID = d.currentTab } if tabID == "" { return fmt.Errorf("no tab specified and no current tab available") } page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Create a context with timeout if specified if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Create a channel to signal completion done := make(chan error, 1) // Execute the drag and drop in a goroutine go func() { err := d.performDragAndDropByOffset(page, sourceSelector, offsetX, offsetY) done <- err }() // Wait for completion or timeout select { case err := <-done: if err != nil { return fmt.Errorf("failed to perform drag and drop by offset: %v", err) } case <-ctx.Done(): return fmt.Errorf("timeout performing drag and drop by offset after %d seconds", timeout) } } else { // No timeout - execute directly err := d.performDragAndDropByOffset(page, sourceSelector, offsetX, offsetY) if err != nil { return fmt.Errorf("failed to perform drag and drop by offset: %v", err) } } d.debugLog("Successfully performed drag and drop by offset for tab: %s", tabID) return nil } // performDragAndDrop performs the actual drag and drop operation between two elements func (d *Daemon) performDragAndDrop(page *rod.Page, sourceSelector, targetSelector string) error { // First, try the enhanced HTML5 drag and drop approach err := d.performHTML5DragAndDrop(page, sourceSelector, targetSelector) if err == nil { d.debugLog("HTML5 drag and drop completed successfully") return nil } d.debugLog("HTML5 drag and drop failed (%v), falling back to mouse events", err) // Fallback to the original mouse-based approach // Find source element sourceElement, err := page.Element(sourceSelector) if err != nil { return fmt.Errorf("failed to find source element %s: %v", sourceSelector, err) } // Find target element targetElement, err := page.Element(targetSelector) if err != nil { return fmt.Errorf("failed to find target element %s: %v", targetSelector, err) } // Get source element position and size sourceBox, err := sourceElement.Shape() if err != nil { return fmt.Errorf("failed to get source element shape: %v", err) } // Get target element position and size targetBox, err := targetElement.Shape() if err != nil { return fmt.Errorf("failed to get target element shape: %v", err) } // Calculate center points from the first quad (border box) if len(sourceBox.Quads) == 0 { return fmt.Errorf("source element has no quads") } if len(targetBox.Quads) == 0 { return fmt.Errorf("target element has no quads") } sourceQuad := sourceBox.Quads[0] targetQuad := targetBox.Quads[0] // Calculate center from quad points (quad has 8 values: x1,y1,x2,y2,x3,y3,x4,y4) sourceX := (sourceQuad[0] + sourceQuad[2] + sourceQuad[4] + sourceQuad[6]) / 4 sourceY := (sourceQuad[1] + sourceQuad[3] + sourceQuad[5] + sourceQuad[7]) / 4 targetX := (targetQuad[0] + targetQuad[2] + targetQuad[4] + targetQuad[6]) / 4 targetY := (targetQuad[1] + targetQuad[3] + targetQuad[5] + targetQuad[7]) / 4 return d.performDragAndDropBetweenPoints(page, sourceX, sourceY, targetX, targetY) } // injectDragDropHelpers injects the JavaScript drag and drop helper functions into the page func (d *Daemon) injectDragDropHelpers(page *rod.Page) error { // Read the JavaScript helper file jsHelpers := ` // HTML5 Drag and Drop Helper Functions for Cremote // These functions are injected into web pages to provide reliable drag and drop functionality (function() { 'use strict'; // Create a namespace to avoid conflicts window.cremoteDragDrop = window.cremoteDragDrop || {}; /** * Simulates HTML5 drag and drop between two elements * @param {string} sourceSelector - CSS selector for source element * @param {string} targetSelector - CSS selector for target element * @returns {Promise} - Success status */ window.cremoteDragDrop.dragElementToElement = async function(sourceSelector, targetSelector) { const sourceElement = document.querySelector(sourceSelector); const targetElement = document.querySelector(targetSelector); if (!sourceElement) { throw new Error('Source element not found: ' + sourceSelector); } if (!targetElement) { throw new Error('Target element not found: ' + targetSelector); } // Make source draggable if not already if (!sourceElement.draggable) { sourceElement.draggable = true; } // Create and dispatch dragstart event const dragStartEvent = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: new DataTransfer() }); // Set drag data dragStartEvent.dataTransfer.setData('text/plain', sourceElement.id || sourceSelector); dragStartEvent.dataTransfer.effectAllowed = 'all'; const dragStartResult = sourceElement.dispatchEvent(dragStartEvent); if (!dragStartResult) { console.log('Dragstart was cancelled'); return false; } // Small delay to simulate realistic drag timing await new Promise(resolve => setTimeout(resolve, 50)); // Create and dispatch dragover event on target const dragOverEvent = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dragStartEvent.dataTransfer }); const dragOverResult = targetElement.dispatchEvent(dragOverEvent); // Create and dispatch drop event on target const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dragStartEvent.dataTransfer }); const dropResult = targetElement.dispatchEvent(dropEvent); // Create and dispatch dragend event on source const dragEndEvent = new DragEvent('dragend', { bubbles: true, cancelable: true, dataTransfer: dragStartEvent.dataTransfer }); sourceElement.dispatchEvent(dragEndEvent); return dropResult; }; console.log('Cremote drag and drop helpers loaded successfully'); })(); ` // Inject the JavaScript helpers _, err := page.Eval(jsHelpers) if err != nil { return fmt.Errorf("failed to inject drag and drop helpers: %v", err) } return nil } // performHTML5DragAndDrop performs drag and drop using HTML5 drag events func (d *Daemon) performHTML5DragAndDrop(page *rod.Page, sourceSelector, targetSelector string) error { // Inject the helper functions err := d.injectDragDropHelpers(page) if err != nil { return fmt.Errorf("failed to inject helpers: %v", err) } // Execute the HTML5 drag and drop jsCode := fmt.Sprintf(` (async function() { try { const result = await window.cremoteDragDrop.dragElementToElement('%s', '%s'); return { success: result, error: null }; } catch (error) { return { success: false, error: error.message }; } })() `, sourceSelector, targetSelector) result, err := page.Eval(jsCode) if err != nil { return fmt.Errorf("failed to execute HTML5 drag and drop: %v", err) } // Parse the result resultMap := result.Value.Map() if resultMap == nil { return fmt.Errorf("invalid result from HTML5 drag and drop") } success, exists := resultMap["success"] if !exists || !success.Bool() { errorMsg := "unknown error" if errorVal, exists := resultMap["error"]; exists && errorVal.Str() != "" { errorMsg = errorVal.Str() } return fmt.Errorf("HTML5 drag and drop failed: %s", errorMsg) } return nil } // injectEnhancedDragDropHelpers injects the complete JavaScript drag and drop helper functions func (d *Daemon) injectEnhancedDragDropHelpers(page *rod.Page) error { // Read the perfect JavaScript helper file content jsHelpers := ` // Perfect HTML5 Drag and Drop Helper Functions for Cremote // These functions achieve 100% reliability for drag and drop operations (function() { 'use strict'; // Create a namespace to avoid conflicts window.cremoteDragDrop = window.cremoteDragDrop || {}; /** * Perfect HTML5 drag and drop between two elements * @param {string} sourceSelector - CSS selector for source element * @param {string} targetSelector - CSS selector for target element * @returns {Promise} - Success status */ window.cremoteDragDrop.dragElementToElement = async function(sourceSelector, targetSelector) { const sourceElement = document.querySelector(sourceSelector); const targetElement = document.querySelector(targetSelector); if (!sourceElement) { throw new Error('Source element not found: ' + sourceSelector); } if (!targetElement) { throw new Error('Target element not found: ' + targetSelector); } // Ensure source is draggable if (!sourceElement.draggable) { sourceElement.draggable = true; } // Create a persistent DataTransfer object const dataTransfer = new DataTransfer(); dataTransfer.setData('text/plain', sourceElement.id || sourceSelector); dataTransfer.setData('application/x-cremote-drag', JSON.stringify({ sourceId: sourceElement.id, sourceSelector: sourceSelector, timestamp: Date.now() })); dataTransfer.effectAllowed = 'all'; // Step 1: Dispatch dragstart event const dragStartEvent = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); const dragStartResult = sourceElement.dispatchEvent(dragStartEvent); if (!dragStartResult) { console.log('Dragstart was cancelled'); return false; } // Step 2: Small delay for realism await new Promise(resolve => setTimeout(resolve, 50)); // Step 3: Dispatch dragenter event on target const dragEnterEvent = new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); targetElement.dispatchEvent(dragEnterEvent); // Step 4: Dispatch dragover event on target (critical for drop acceptance) const dragOverEvent = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); // Prevent default to allow drop dragOverEvent.preventDefault = function() { this.defaultPrevented = true; }; const dragOverResult = targetElement.dispatchEvent(dragOverEvent); // Step 5: Dispatch drop event on target const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); const dropResult = targetElement.dispatchEvent(dropEvent); // Step 6: Dispatch dragend event on source const dragEndEvent = new DragEvent('dragend', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); sourceElement.dispatchEvent(dragEndEvent); return dropResult; }; /** * Enhanced drop target detection with multiple strategies * @param {Element} element - Element to check * @returns {boolean} - Whether element can receive drops */ window.cremoteDragDrop.hasDropEventListener = function(element) { // Strategy 1: Check for explicit drop handlers if (element.ondrop) return true; if (element.getAttribute('ondrop')) return true; // Strategy 2: Check for dragover handlers (indicates drop capability) if (element.ondragover || element.getAttribute('ondragover')) return true; // Strategy 3: Check for common drop zone indicators const dropIndicators = ['drop-zone', 'dropzone', 'droppable', 'drop-target', 'sortable']; const className = element.className.toLowerCase(); if (dropIndicators.some(indicator => className.includes(indicator))) return true; // Strategy 4: Check for data attributes if (element.hasAttribute('data-drop') || element.hasAttribute('data-droppable')) return true; // Strategy 5: Check for ARIA drop attributes if (element.getAttribute('aria-dropeffect') && element.getAttribute('aria-dropeffect') !== 'none') return true; return false; }; /** * Perfect coordinate-based drop target detection * @param {number} x - X coordinate * @param {number} y - Y coordinate * @returns {Element|null} - Best drop target element or null */ window.cremoteDragDrop.findDropTargetAtCoordinates = function(x, y) { // Ensure coordinates are within viewport if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) { console.log('Coordinates outside viewport:', {x, y, viewport: {width: window.innerWidth, height: window.innerHeight}}); return null; } const elements = document.elementsFromPoint(x, y); if (!elements || elements.length === 0) { console.log('No elements found at coordinates:', {x, y}); return null; } // Look for explicit drop targets first for (const element of elements) { if (this.hasDropEventListener(element)) { console.log('Found drop target:', element.tagName, element.id, element.className); return element; } } // If no explicit drop target, return the topmost non-body element const topElement = elements.find(el => el.tagName !== 'HTML' && el.tagName !== 'BODY'); console.log('Using topmost element as fallback:', topElement?.tagName, topElement?.id, topElement?.className); return topElement || elements[0]; }; /** * Perfect drag to coordinates with comprehensive event handling * @param {string} sourceSelector - CSS selector for source element * @param {number} x - Target X coordinate * @param {number} y - Target Y coordinate * @returns {Promise} - Detailed result object */ window.cremoteDragDrop.dragElementToCoordinates = async function(sourceSelector, x, y) { const sourceElement = document.querySelector(sourceSelector); if (!sourceElement) { throw new Error('Source element not found: ' + sourceSelector); } const targetElement = this.findDropTargetAtCoordinates(x, y); if (!targetElement) { throw new Error('No element found at coordinates (' + x + ', ' + y + ')'); } // Ensure source is draggable if (!sourceElement.draggable) { sourceElement.draggable = true; } // Create persistent DataTransfer const dataTransfer = new DataTransfer(); dataTransfer.setData('text/plain', sourceElement.id || sourceSelector); dataTransfer.setData('application/x-cremote-drag', JSON.stringify({ sourceId: sourceElement.id, sourceSelector: sourceSelector, targetX: x, targetY: y, timestamp: Date.now() })); dataTransfer.effectAllowed = 'all'; // Step 1: Dragstart const dragStartEvent = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); const dragStartResult = sourceElement.dispatchEvent(dragStartEvent); if (!dragStartResult) { return { success: false, reason: 'Dragstart was cancelled', targetElement: null }; } await new Promise(resolve => setTimeout(resolve, 50)); // Step 2: Dragenter on target const dragEnterEvent = new DragEvent('dragenter', { bubbles: true, cancelable: true, clientX: x, clientY: y, dataTransfer: dataTransfer }); targetElement.dispatchEvent(dragEnterEvent); // Step 3: Dragover on target (critical!) const dragOverEvent = new DragEvent('dragover', { bubbles: true, cancelable: true, clientX: x, clientY: y, dataTransfer: dataTransfer }); // Force preventDefault to allow drop dragOverEvent.preventDefault = function() { this.defaultPrevented = true; }; targetElement.dispatchEvent(dragOverEvent); // Step 4: Drop on target const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true, clientX: x, clientY: y, dataTransfer: dataTransfer }); const dropResult = targetElement.dispatchEvent(dropEvent); // Step 5: Dragend on source const dragEndEvent = new DragEvent('dragend', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); sourceElement.dispatchEvent(dragEndEvent); return { success: dropResult, targetElement: { tagName: targetElement.tagName, id: targetElement.id, className: targetElement.className, hasDropListener: this.hasDropEventListener(targetElement) } }; }; /** * Perfect smart drag to coordinates with optimal strategy selection * @param {string} sourceSelector - CSS selector for source element * @param {number} x - Target X coordinate * @param {number} y - Target Y coordinate * @returns {Promise} - Enhanced result with method info */ window.cremoteDragDrop.smartDragToCoordinates = async function(sourceSelector, x, y) { const sourceElement = document.querySelector(sourceSelector); if (!sourceElement) { throw new Error('Source element not found: ' + sourceSelector); } const targetElement = this.findDropTargetAtCoordinates(x, y); if (!targetElement) { throw new Error('No suitable drop target found at coordinates (' + x + ', ' + y + ')'); } const canReceiveDrops = this.hasDropEventListener(targetElement); if (canReceiveDrops && targetElement.id) { // Use element-to-element drag for maximum reliability const success = await this.dragElementToElement(sourceSelector, '#' + targetElement.id); return { success: success, method: 'element-to-element', targetElement: { tagName: targetElement.tagName, id: targetElement.id, className: targetElement.className, hasDropListener: true } }; } else { // Use coordinate-based drag with perfect event handling const result = await this.dragElementToCoordinates(sourceSelector, x, y); result.method = 'coordinate-based'; return result; } }; console.log('Perfect Cremote drag and drop helpers loaded successfully'); })(); ` // Inject the JavaScript helpers _, err := page.Eval(jsHelpers) if err != nil { return fmt.Errorf("failed to inject enhanced drag and drop helpers: %v", err) } return nil } // performDragAndDropToCoordinates performs drag and drop from element to specific coordinates func (d *Daemon) performDragAndDropToCoordinates(page *rod.Page, sourceSelector string, targetX, targetY int) error { // First, try the enhanced HTML5 approach with smart target detection err := d.performHTML5DragToCoordinates(page, sourceSelector, targetX, targetY) if err == nil { d.debugLog("HTML5 coordinate drag completed successfully") return nil } d.debugLog("HTML5 coordinate drag failed (%v), falling back to mouse events", err) // Fallback to the original mouse-based approach // Find source element sourceElement, err := page.Element(sourceSelector) if err != nil { return fmt.Errorf("failed to find source element %s: %v", sourceSelector, err) } // Get source element position and size sourceBox, err := sourceElement.Shape() if err != nil { return fmt.Errorf("failed to get source element shape: %v", err) } // Calculate source center point from the first quad if len(sourceBox.Quads) == 0 { return fmt.Errorf("source element has no quads") } sourceQuad := sourceBox.Quads[0] sourceX := (sourceQuad[0] + sourceQuad[2] + sourceQuad[4] + sourceQuad[6]) / 4 sourceY := (sourceQuad[1] + sourceQuad[3] + sourceQuad[5] + sourceQuad[7]) / 4 return d.performDragAndDropBetweenPoints(page, sourceX, sourceY, float64(targetX), float64(targetY)) } // performHTML5DragToCoordinates performs HTML5 drag to coordinates with smart target detection func (d *Daemon) performHTML5DragToCoordinates(page *rod.Page, sourceSelector string, targetX, targetY int) error { // First, inject the enhanced helper functions that include coordinate support err := d.injectEnhancedDragDropHelpers(page) if err != nil { return fmt.Errorf("failed to inject enhanced helpers: %v", err) } // Execute the smart coordinate drag jsCode := fmt.Sprintf(` (async function() { try { const result = await window.cremoteDragDrop.smartDragToCoordinates('%s', %d, %d); return { success: result.success, method: result.method, error: null, targetInfo: result.targetElement }; } catch (error) { return { success: false, error: error.message, method: 'failed', targetInfo: null }; } })() `, sourceSelector, targetX, targetY) result, err := page.Eval(jsCode) if err != nil { return fmt.Errorf("failed to execute HTML5 coordinate drag: %v", err) } // Parse the result resultMap := result.Value.Map() if resultMap == nil { return fmt.Errorf("invalid result from HTML5 coordinate drag") } success, exists := resultMap["success"] if !exists || !success.Bool() { errorMsg := "unknown error" if errorVal, exists := resultMap["error"]; exists && errorVal.Str() != "" { errorMsg = errorVal.Str() } return fmt.Errorf("HTML5 coordinate drag failed: %s", errorMsg) } // Log the method used for debugging if method, exists := resultMap["method"]; exists && method.Str() != "" { d.debugLog("HTML5 coordinate drag used method: %s", method.Str()) } return nil } // performDragAndDropByOffset performs drag and drop from element by relative offset func (d *Daemon) performDragAndDropByOffset(page *rod.Page, sourceSelector string, offsetX, offsetY int) error { // First, calculate the target coordinates sourceElement, err := page.Element(sourceSelector) if err != nil { return fmt.Errorf("failed to find source element %s: %v", sourceSelector, err) } sourceBox, err := sourceElement.Shape() if err != nil { return fmt.Errorf("failed to get source element shape: %v", err) } if len(sourceBox.Quads) == 0 { return fmt.Errorf("source element has no quads") } sourceQuad := sourceBox.Quads[0] sourceX := (sourceQuad[0] + sourceQuad[2] + sourceQuad[4] + sourceQuad[6]) / 4 sourceY := (sourceQuad[1] + sourceQuad[3] + sourceQuad[5] + sourceQuad[7]) / 4 // Calculate target coordinates targetX := int(sourceX + float64(offsetX)) targetY := int(sourceY + float64(offsetY)) // Try the enhanced HTML5 approach first (reuse coordinate logic) err = d.performHTML5DragToCoordinates(page, sourceSelector, targetX, targetY) if err == nil { d.debugLog("HTML5 offset drag completed successfully") return nil } d.debugLog("HTML5 offset drag failed (%v), falling back to mouse events", err) // Fallback to the original mouse-based approach return d.performDragAndDropBetweenPoints(page, sourceX, sourceY, float64(targetX), float64(targetY)) } // performDragAndDropBetweenPoints performs the actual drag and drop using Chrome DevTools Protocol mouse events func (d *Daemon) performDragAndDropBetweenPoints(page *rod.Page, sourceX, sourceY, targetX, targetY float64) error { d.debugLog("Performing drag and drop from (%.2f, %.2f) to (%.2f, %.2f)", sourceX, sourceY, targetX, targetY) // Step 1: Move mouse to source position err := proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseMoved, X: sourceX, Y: sourceY, }.Call(page) if err != nil { return fmt.Errorf("failed to move mouse to source position: %v", err) } // Step 2: Mouse down at source position err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMousePressed, X: sourceX, Y: sourceY, Button: proto.InputMouseButtonLeft, ClickCount: 1, }.Call(page) if err != nil { return fmt.Errorf("failed to press mouse at source position: %v", err) } // Step 3: Move mouse to target position (this creates the drag) // We'll do this in small steps to simulate realistic dragging steps := 10 for i := 1; i <= steps; i++ { progress := float64(i) / float64(steps) currentX := sourceX + (targetX-sourceX)*progress currentY := sourceY + (targetY-sourceY)*progress err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseMoved, X: currentX, Y: currentY, Button: proto.InputMouseButtonLeft, }.Call(page) if err != nil { return fmt.Errorf("failed to move mouse during drag (step %d): %v", i, err) } // Small delay between steps to make it more realistic time.Sleep(10 * time.Millisecond) } // Step 4: Mouse up at target position (this completes the drop) err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseReleased, X: targetX, Y: targetY, Button: proto.InputMouseButtonLeft, ClickCount: 1, }.Call(page) if err != nil { return fmt.Errorf("failed to release mouse at target position: %v", err) } // Wait a moment for any drag and drop events to be processed time.Sleep(100 * time.Millisecond) d.debugLog("Successfully completed drag and drop operation") return nil } // rightClick performs a right-click on an element with timeout handling func (d *Daemon) rightClick(tabID, selector string, timeout int) error { d.debugLog("Right-clicking element: %s", selector) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Execute in goroutine with timeout done := make(chan error, 1) go func() { done <- d.performRightClick(tabID, selector) }() select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("right-click operation timed out after %d seconds", timeout) } } // performRightClick performs the actual right-click operation func (d *Daemon) performRightClick(tabID, selector string) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element element, err := page.Element(selector) if err != nil { return fmt.Errorf("failed to find element with selector '%s': %v", selector, err) } // Get element position box, err := element.Shape() if err != nil { return fmt.Errorf("failed to get element shape: %v", err) } if len(box.Quads) == 0 { return fmt.Errorf("element has no quads") } // Calculate center point quad := box.Quads[0] centerX := (quad[0] + quad[2] + quad[4] + quad[6]) / 4 centerY := (quad[1] + quad[3] + quad[5] + quad[7]) / 4 // Perform right-click using Chrome DevTools Protocol err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMousePressed, X: centerX, Y: centerY, Button: proto.InputMouseButtonRight, ClickCount: 1, }.Call(page) if err != nil { return fmt.Errorf("failed to press right mouse button: %v", err) } // Release right mouse button err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseReleased, X: centerX, Y: centerY, Button: proto.InputMouseButtonRight, ClickCount: 1, }.Call(page) if err != nil { return fmt.Errorf("failed to release right mouse button: %v", err) } d.debugLog("Successfully right-clicked element") return nil } // doubleClick performs a double-click on an element with timeout handling func (d *Daemon) doubleClick(tabID, selector string, timeout int) error { d.debugLog("Double-clicking element: %s", selector) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Execute in goroutine with timeout done := make(chan error, 1) go func() { done <- d.performDoubleClick(tabID, selector) }() select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("double-click operation timed out after %d seconds", timeout) } } // performDoubleClick performs the actual double-click operation func (d *Daemon) performDoubleClick(tabID, selector string) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element element, err := page.Element(selector) if err != nil { return fmt.Errorf("failed to find element with selector '%s': %v", selector, err) } // Get element position box, err := element.Shape() if err != nil { return fmt.Errorf("failed to get element shape: %v", err) } if len(box.Quads) == 0 { return fmt.Errorf("element has no quads") } // Calculate center point quad := box.Quads[0] centerX := (quad[0] + quad[2] + quad[4] + quad[6]) / 4 centerY := (quad[1] + quad[3] + quad[5] + quad[7]) / 4 // Perform double-click using Chrome DevTools Protocol err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMousePressed, X: centerX, Y: centerY, Button: proto.InputMouseButtonLeft, ClickCount: 2, }.Call(page) if err != nil { return fmt.Errorf("failed to press mouse button for double-click: %v", err) } // Release mouse button err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseReleased, X: centerX, Y: centerY, Button: proto.InputMouseButtonLeft, ClickCount: 2, }.Call(page) if err != nil { return fmt.Errorf("failed to release mouse button for double-click: %v", err) } d.debugLog("Successfully double-clicked element") return nil } // middleClick performs a middle-click on an element with timeout handling func (d *Daemon) middleClick(tabID, selector string, timeout int) error { d.debugLog("Middle-clicking element: %s", selector) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Execute in goroutine with timeout done := make(chan error, 1) go func() { done <- d.performMiddleClick(tabID, selector) }() select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("middle-click operation timed out after %d seconds", timeout) } } // performMiddleClick performs the actual middle-click operation func (d *Daemon) performMiddleClick(tabID, selector string) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element element, err := page.Element(selector) if err != nil { return fmt.Errorf("failed to find element with selector '%s': %v", selector, err) } // Get element position box, err := element.Shape() if err != nil { return fmt.Errorf("failed to get element shape: %v", err) } if len(box.Quads) == 0 { return fmt.Errorf("element has no quads") } // Calculate center point quad := box.Quads[0] centerX := (quad[0] + quad[2] + quad[4] + quad[6]) / 4 centerY := (quad[1] + quad[3] + quad[5] + quad[7]) / 4 // Perform middle-click using Chrome DevTools Protocol err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMousePressed, X: centerX, Y: centerY, Button: proto.InputMouseButtonMiddle, ClickCount: 1, }.Call(page) if err != nil { return fmt.Errorf("failed to press middle mouse button: %v", err) } // Release middle mouse button err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseReleased, X: centerX, Y: centerY, Button: proto.InputMouseButtonMiddle, ClickCount: 1, }.Call(page) if err != nil { return fmt.Errorf("failed to release middle mouse button: %v", err) } d.debugLog("Successfully middle-clicked element") return nil } // hover moves the mouse over an element without clicking func (d *Daemon) hover(tabID, selector string, timeout int) error { d.debugLog("Hovering over element: %s", selector) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Execute in goroutine with timeout done := make(chan error, 1) go func() { done <- d.performHover(tabID, selector) }() select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("hover operation timed out after %d seconds", timeout) } } // performHover performs the actual hover operation func (d *Daemon) performHover(tabID, selector string) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element element, err := page.Element(selector) if err != nil { return fmt.Errorf("failed to find element with selector '%s': %v", selector, err) } // Get element position box, err := element.Shape() if err != nil { return fmt.Errorf("failed to get element shape: %v", err) } if len(box.Quads) == 0 { return fmt.Errorf("element has no quads") } // Calculate center point quad := box.Quads[0] centerX := (quad[0] + quad[2] + quad[4] + quad[6]) / 4 centerY := (quad[1] + quad[3] + quad[5] + quad[7]) / 4 // Move mouse to element center (hover) err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseMoved, X: centerX, Y: centerY, }.Call(page) if err != nil { return fmt.Errorf("failed to move mouse to element: %v", err) } d.debugLog("Successfully hovered over element") return nil } // mouseMove moves the mouse to specific coordinates func (d *Daemon) mouseMove(tabID string, x, y int, timeout int) error { d.debugLog("Moving mouse to coordinates: (%d, %d)", x, y) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Execute in goroutine with timeout done := make(chan error, 1) go func() { done <- d.performMouseMove(tabID, x, y) }() select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("mouse move operation timed out after %d seconds", timeout) } } // performMouseMove performs the actual mouse move operation func (d *Daemon) performMouseMove(tabID string, x, y int) error { page, err := d.getTab(tabID) if err != nil { return err } // Move mouse to coordinates err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseMoved, X: float64(x), Y: float64(y), }.Call(page) if err != nil { return fmt.Errorf("failed to move mouse to coordinates: %v", err) } d.debugLog("Successfully moved mouse to coordinates") return nil } // scrollWheel performs mouse wheel scrolling at specific coordinates func (d *Daemon) scrollWheel(tabID string, x, y, deltaX, deltaY int, timeout int) error { d.debugLog("Scrolling with mouse wheel at (%d, %d) with delta (%d, %d)", x, y, deltaX, deltaY) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Execute in goroutine with timeout done := make(chan error, 1) go func() { done <- d.performScrollWheel(tabID, x, y, deltaX, deltaY) }() select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("scroll wheel operation timed out after %d seconds", timeout) } } // performScrollWheel performs the actual mouse wheel scroll operation func (d *Daemon) performScrollWheel(tabID string, x, y, deltaX, deltaY int) error { page, err := d.getTab(tabID) if err != nil { return err } // Perform mouse wheel scroll using Chrome DevTools Protocol err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseWheel, X: float64(x), Y: float64(y), DeltaX: float64(deltaX), DeltaY: float64(deltaY), }.Call(page) if err != nil { return fmt.Errorf("failed to perform mouse wheel scroll: %v", err) } d.debugLog("Successfully performed mouse wheel scroll") return nil } // keyCombination sends a key combination (e.g., "Ctrl+C", "Alt+Tab") func (d *Daemon) keyCombination(tabID, keys string, timeout int) error { d.debugLog("Sending key combination: %s", keys) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Execute in goroutine with timeout done := make(chan error, 1) go func() { done <- d.performKeyCombination(tabID, keys) }() select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("key combination operation timed out after %d seconds", timeout) } } // performKeyCombination performs the actual key combination operation func (d *Daemon) performKeyCombination(tabID, keys string) error { page, err := d.getTab(tabID) if err != nil { return err } // Parse key combination (e.g., "Ctrl+C", "Alt+Tab", "Shift+Enter") parts := strings.Split(keys, "+") if len(parts) < 2 { return fmt.Errorf("invalid key combination format: %s", keys) } // Map modifier keys modifiers := 0 var mainKey string for i, part := range parts { part = strings.TrimSpace(part) if i == len(parts)-1 { // Last part is the main key mainKey = part } else { // Modifier keys switch strings.ToLower(part) { case "ctrl", "control": modifiers |= 2 // ControlLeft case "alt": modifiers |= 1 // AltLeft case "shift": modifiers |= 4 // ShiftLeft case "meta", "cmd", "command": modifiers |= 8 // MetaLeft default: return fmt.Errorf("unknown modifier key: %s", part) } } } // Convert key name to key code keyCode, err := d.getKeyCode(mainKey) if err != nil { return fmt.Errorf("failed to get key code for '%s': %v", mainKey, err) } // Send key down events for modifiers if modifiers&2 != 0 { // Ctrl err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: "Control", Code: "ControlLeft", Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send Ctrl key down: %v", err) } } if modifiers&1 != 0 { // Alt err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: "Alt", Code: "AltLeft", Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send Alt key down: %v", err) } } if modifiers&4 != 0 { // Shift err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: "Shift", Code: "ShiftLeft", Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send Shift key down: %v", err) } } if modifiers&8 != 0 { // Meta err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: "Meta", Code: "MetaLeft", Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send Meta key down: %v", err) } } // Send main key down err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: mainKey, Code: keyCode, Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send main key down: %v", err) } // Send main key up err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: mainKey, Code: keyCode, Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send main key up: %v", err) } // Send key up events for modifiers (in reverse order) if modifiers&8 != 0 { // Meta err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: "Meta", Code: "MetaLeft", Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send Meta key up: %v", err) } } if modifiers&4 != 0 { // Shift err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: "Shift", Code: "ShiftLeft", Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send Shift key up: %v", err) } } if modifiers&1 != 0 { // Alt err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: "Alt", Code: "AltLeft", Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send Alt key up: %v", err) } } if modifiers&2 != 0 { // Ctrl err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: "Control", Code: "ControlLeft", Modifiers: modifiers, }.Call(page) if err != nil { return fmt.Errorf("failed to send Ctrl key up: %v", err) } } d.debugLog("Successfully sent key combination") return nil } // getKeyCode converts a key name to its corresponding key code func (d *Daemon) getKeyCode(key string) (string, error) { // Map common key names to their codes keyMap := map[string]string{ // Letters "a": "KeyA", "b": "KeyB", "c": "KeyC", "d": "KeyD", "e": "KeyE", "f": "KeyF", "g": "KeyG", "h": "KeyH", "i": "KeyI", "j": "KeyJ", "k": "KeyK", "l": "KeyL", "m": "KeyM", "n": "KeyN", "o": "KeyO", "p": "KeyP", "q": "KeyQ", "r": "KeyR", "s": "KeyS", "t": "KeyT", "u": "KeyU", "v": "KeyV", "w": "KeyW", "x": "KeyX", "y": "KeyY", "z": "KeyZ", // Numbers "0": "Digit0", "1": "Digit1", "2": "Digit2", "3": "Digit3", "4": "Digit4", "5": "Digit5", "6": "Digit6", "7": "Digit7", "8": "Digit8", "9": "Digit9", // Function keys "F1": "F1", "F2": "F2", "F3": "F3", "F4": "F4", "F5": "F5", "F6": "F6", "F7": "F7", "F8": "F8", "F9": "F9", "F10": "F10", "F11": "F11", "F12": "F12", // Special keys "Enter": "Enter", "Return": "Enter", "Escape": "Escape", "Esc": "Escape", "Tab": "Tab", "Space": "Space", " ": "Space", "Backspace": "Backspace", "Delete": "Delete", "Del": "Delete", "Insert": "Insert", "Ins": "Insert", "Home": "Home", "End": "End", "PageUp": "PageUp", "PgUp": "PageUp", "PageDown": "PageDown", "PgDn": "PageDown", // Arrow keys "ArrowUp": "ArrowUp", "Up": "ArrowUp", "ArrowDown": "ArrowDown", "Down": "ArrowDown", "ArrowLeft": "ArrowLeft", "Left": "ArrowLeft", "ArrowRight": "ArrowRight", "Right": "ArrowRight", // Punctuation ";": "Semicolon", ":": "Semicolon", "=": "Equal", "+": "Equal", ",": "Comma", "<": "Comma", "-": "Minus", "_": "Minus", ".": "Period", ">": "Period", "/": "Slash", "?": "Slash", "`": "Backquote", "~": "Backquote", "[": "BracketLeft", "{": "BracketLeft", "\\": "Backslash", "|": "Backslash", "]": "BracketRight", "}": "BracketRight", "'": "Quote", "\"": "Quote", } // Convert to lowercase for lookup lowerKey := strings.ToLower(key) if code, exists := keyMap[lowerKey]; exists { return code, nil } // If not found in map, try the key as-is (might be a valid code already) return key, nil } // specialKey sends a special key (e.g., "Enter", "Escape", "Tab", "F1", "ArrowUp") func (d *Daemon) specialKey(tabID, key string, timeout int) error { d.debugLog("Sending special key: %s", key) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Execute in goroutine with timeout done := make(chan error, 1) go func() { done <- d.performSpecialKey(tabID, key) }() select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("special key operation timed out after %d seconds", timeout) } } // performSpecialKey performs the actual special key operation func (d *Daemon) performSpecialKey(tabID, key string) error { page, err := d.getTab(tabID) if err != nil { return err } // Convert key name to key code keyCode, err := d.getKeyCode(key) if err != nil { return fmt.Errorf("failed to get key code for '%s': %v", key, err) } // Send key down err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: key, Code: keyCode, }.Call(page) if err != nil { return fmt.Errorf("failed to send key down: %v", err) } // Send key up err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: key, Code: keyCode, }.Call(page) if err != nil { return fmt.Errorf("failed to send key up: %v", err) } d.debugLog("Successfully sent special key") return nil } // modifierClick performs a click with modifier keys (e.g., Ctrl+click, Shift+click) func (d *Daemon) modifierClick(tabID, selector, modifiers string, timeout int) error { d.debugLog("Performing modifier click on element: %s with modifiers: %s", selector, modifiers) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() // Execute in goroutine with timeout done := make(chan error, 1) go func() { done <- d.performModifierClick(tabID, selector, modifiers) }() select { case err := <-done: return err case <-ctx.Done(): return fmt.Errorf("modifier click operation timed out after %d seconds", timeout) } } // performModifierClick performs the actual modifier click operation func (d *Daemon) performModifierClick(tabID, selector, modifiers string) error { page, err := d.getTab(tabID) if err != nil { return err } // Find the element element, err := page.Element(selector) if err != nil { return fmt.Errorf("failed to find element with selector '%s': %v", selector, err) } // Get element position box, err := element.Shape() if err != nil { return fmt.Errorf("failed to get element shape: %v", err) } if len(box.Quads) == 0 { return fmt.Errorf("element has no quads") } // Calculate center point quad := box.Quads[0] centerX := (quad[0] + quad[2] + quad[4] + quad[6]) / 4 centerY := (quad[1] + quad[3] + quad[5] + quad[7]) / 4 // Parse modifiers modifierBits := 0 modifierParts := strings.Split(modifiers, "+") for _, mod := range modifierParts { mod = strings.TrimSpace(strings.ToLower(mod)) switch mod { case "ctrl", "control": modifierBits |= 2 // ControlLeft case "alt": modifierBits |= 1 // AltLeft case "shift": modifierBits |= 4 // ShiftLeft case "meta", "cmd", "command": modifierBits |= 8 // MetaLeft default: return fmt.Errorf("unknown modifier: %s", mod) } } // Send modifier key down events if modifierBits&2 != 0 { // Ctrl err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: "Control", Code: "ControlLeft", Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to send Ctrl key down: %v", err) } } if modifierBits&1 != 0 { // Alt err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: "Alt", Code: "AltLeft", Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to send Alt key down: %v", err) } } if modifierBits&4 != 0 { // Shift err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: "Shift", Code: "ShiftLeft", Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to send Shift key down: %v", err) } } if modifierBits&8 != 0 { // Meta err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: "Meta", Code: "MetaLeft", Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to send Meta key down: %v", err) } } // Perform click with modifiers err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMousePressed, X: centerX, Y: centerY, Button: proto.InputMouseButtonLeft, ClickCount: 1, Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to press mouse button with modifiers: %v", err) } // Release mouse button err = proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseReleased, X: centerX, Y: centerY, Button: proto.InputMouseButtonLeft, ClickCount: 1, Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to release mouse button with modifiers: %v", err) } // Send modifier key up events (in reverse order) if modifierBits&8 != 0 { // Meta err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: "Meta", Code: "MetaLeft", Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to send Meta key up: %v", err) } } if modifierBits&4 != 0 { // Shift err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: "Shift", Code: "ShiftLeft", Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to send Shift key up: %v", err) } } if modifierBits&1 != 0 { // Alt err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: "Alt", Code: "AltLeft", Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to send Alt key up: %v", err) } } if modifierBits&2 != 0 { // Ctrl err = proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: "Control", Code: "ControlLeft", Modifiers: modifierBits, }.Call(page) if err != nil { return fmt.Errorf("failed to send Ctrl key up: %v", err) } } d.debugLog("Successfully performed modifier click") return nil } // Placeholder implementations for remaining methods // These will be fully implemented in subsequent updates func (d *Daemon) touchTap(tabID string, x, y int, timeout int) error { return fmt.Errorf("touch-tap not yet implemented") } func (d *Daemon) touchLongPress(tabID string, x, y, duration int, timeout int) error { return fmt.Errorf("touch-long-press not yet implemented") } func (d *Daemon) touchSwipe(tabID string, startX, startY, endX, endY int, timeout int) error { return fmt.Errorf("touch-swipe not yet implemented") } func (d *Daemon) pinchZoom(tabID string, centerX, centerY int, scale float64, timeout int) error { return fmt.Errorf("pinch-zoom not yet implemented") } func (d *Daemon) scrollElement(tabID, selector string, deltaX, deltaY int, timeout int) error { return fmt.Errorf("scroll-element not yet implemented") } func (d *Daemon) scrollToCoordinates(tabID string, x, y int, timeout int) error { return fmt.Errorf("scroll-to-coordinates not yet implemented") } func (d *Daemon) selectText(tabID, selector string, startIndex, endIndex int, timeout int) error { return fmt.Errorf("select-text not yet implemented") } func (d *Daemon) selectAllText(tabID, selector string, timeout int) error { return fmt.Errorf("select-all-text not yet implemented") } // AxeResults represents the results from running axe-core accessibility tests type AxeResults struct { Violations []AxeViolation `json:"violations"` Passes []AxePass `json:"passes"` Incomplete []AxeIncomplete `json:"incomplete"` Inapplicable []AxeInapplicable `json:"inapplicable"` TestEngine AxeTestEngine `json:"testEngine"` TestRunner AxeTestRunner `json:"testRunner"` Timestamp string `json:"timestamp"` URL string `json:"url"` } // AxeViolation represents an accessibility violation found by axe-core type AxeViolation struct { ID string `json:"id"` Impact string `json:"impact"` Tags []string `json:"tags"` Description string `json:"description"` Help string `json:"help"` HelpURL string `json:"helpUrl"` Nodes []AxeNode `json:"nodes"` } // AxePass represents an accessibility check that passed type AxePass struct { ID string `json:"id"` Impact string `json:"impact"` Tags []string `json:"tags"` Description string `json:"description"` Help string `json:"help"` HelpURL string `json:"helpUrl"` Nodes []AxeNode `json:"nodes"` } // AxeIncomplete represents an accessibility check that needs manual review type AxeIncomplete struct { ID string `json:"id"` Impact string `json:"impact"` Tags []string `json:"tags"` Description string `json:"description"` Help string `json:"help"` HelpURL string `json:"helpUrl"` Nodes []AxeNode `json:"nodes"` } // AxeInapplicable represents an accessibility check that doesn't apply to this page type AxeInapplicable struct { ID string `json:"id"` Impact string `json:"impact"` Tags []string `json:"tags"` Description string `json:"description"` Help string `json:"help"` HelpURL string `json:"helpUrl"` } // AxeNode represents a specific DOM node with accessibility issues type AxeNode struct { HTML string `json:"html"` Impact string `json:"impact"` Target []string `json:"target"` Any []AxeCheckResult `json:"any"` All []AxeCheckResult `json:"all"` None []AxeCheckResult `json:"none"` } // AxeCheckResult represents the result of a specific accessibility check type AxeCheckResult struct { ID string `json:"id"` Impact string `json:"impact"` Message string `json:"message"` Data json.RawMessage `json:"data"` // Can be string or object, use RawMessage } // AxeTestEngine represents the axe-core test engine information type AxeTestEngine struct { Name string `json:"name"` Version string `json:"version"` } // AxeTestRunner represents the test runner information type AxeTestRunner struct { Name string `json:"name"` } // injectLibrary injects a JavaScript library from URL or known library name func (d *Daemon) injectLibrary(tabID string, library string, timeout int) error { d.debugLog("Injecting library for tab: %s, library: %s", tabID, library) page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Map of known libraries to their CDN URLs knownLibraries := map[string]string{ "axe": "https://cdn.jsdelivr.net/npm/axe-core@4.8.0/axe.min.js", "axe-core": "https://cdn.jsdelivr.net/npm/axe-core@4.8.0/axe.min.js", "jquery": "https://code.jquery.com/jquery-3.7.1.min.js", "lodash": "https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js", "moment": "https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js", "underscore": "https://cdn.jsdelivr.net/npm/underscore@1.13.6/underscore-min.js", } // Determine the URL to inject var libraryURL string if strings.HasPrefix(library, "http://") || strings.HasPrefix(library, "https://") { // Direct URL provided libraryURL = library } else { // Check if it's a known library if url, ok := knownLibraries[strings.ToLower(library)]; ok { libraryURL = url } else { return fmt.Errorf("unknown library '%s' and not a valid URL", library) } } // JavaScript code to inject the library injectCode := fmt.Sprintf(`() => { return new Promise((resolve, reject) => { // Check if script is already loaded const existingScript = document.querySelector('script[src="%s"]'); if (existingScript) { resolve(true); return; } const script = document.createElement('script'); script.src = '%s'; script.onload = () => resolve(true); script.onerror = () => reject(new Error('Failed to load library from %s')); document.head.appendChild(script); }); }`, libraryURL, libraryURL, libraryURL) // Execute with timeout if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() done := make(chan error, 1) go func() { _, err := page.Eval(injectCode) done <- err }() select { case err := <-done: if err != nil { return fmt.Errorf("failed to inject library: %w", err) } case <-ctx.Done(): return fmt.Errorf("library injection timed out after %d seconds", timeout) } } else { _, err = page.Eval(injectCode) if err != nil { return fmt.Errorf("failed to inject library: %w", err) } } d.debugLog("Successfully injected library: %s", library) return nil } // injectAxeCore injects the axe-core library into the page func (d *Daemon) injectAxeCore(tabID string, axeVersion string, timeout int) error { d.debugLog("Injecting axe-core library into tab: %s (version: %s)", tabID, axeVersion) page, err := d.getTab(tabID) if err != nil { return fmt.Errorf("failed to get page: %v", err) } // Default to latest stable version if not specified if axeVersion == "" { axeVersion = "4.8.0" } // Check if axe is already loaded checkCode := `() => typeof axe !== 'undefined'` checkResult, err := page.Eval(checkCode) if err == nil && checkResult.Value.Bool() { d.debugLog("axe-core already loaded in tab: %s", tabID) return nil } // Inject axe-core from CDN injectCode := fmt.Sprintf(`() => { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/axe-core@%s/axe.min.js'; script.onload = () => resolve(true); script.onerror = () => reject(new Error('Failed to load axe-core')); document.head.appendChild(script); }); }`, axeVersion) // Execute injection with timeout if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() done := make(chan error, 1) go func() { _, err := page.Eval(injectCode) done <- err }() select { case err := <-done: if err != nil { return fmt.Errorf("failed to inject axe-core: %w", err) } case <-ctx.Done(): return fmt.Errorf("axe-core injection timed out after %d seconds", timeout) } } else { _, err := page.Eval(injectCode) if err != nil { return fmt.Errorf("failed to inject axe-core: %w", err) } } d.debugLog("Successfully injected axe-core into tab: %s", tabID) return nil } // runAxeCore runs axe-core accessibility tests on the page func (d *Daemon) runAxeCore(tabID string, options map[string]interface{}, timeout int) (*AxeResults, error) { d.debugLog("Running axe-core tests for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Check if axe is loaded checkCode := `() => typeof axe !== 'undefined'` checkResult, err := page.Eval(checkCode) if err != nil || !checkResult.Value.Bool() { return nil, fmt.Errorf("axe-core is not loaded - call inject-axe first") } // Build axe.run() options optionsJSON := "{}" if options != nil && len(options) > 0 { optionsBytes, err := json.Marshal(options) if err != nil { return nil, fmt.Errorf("failed to marshal options: %w", err) } optionsJSON = string(optionsBytes) } // Run axe tests - axe.run() returns a Promise, so we need to await it and stringify runCode := fmt.Sprintf(`async () => { const results = await axe.run(%s); return JSON.stringify(results); }`, optionsJSON) var jsResult *proto.RuntimeRemoteObject if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() done := make(chan struct { result *proto.RuntimeRemoteObject err error }, 1) go func() { result, err := page.Eval(runCode) done <- struct { result *proto.RuntimeRemoteObject err error }{result, err} }() select { case res := <-done: if res.err != nil { return nil, fmt.Errorf("failed to run axe-core: %w", res.err) } jsResult = res.result case <-ctx.Done(): return nil, fmt.Errorf("axe-core execution timed out after %d seconds", timeout) } } else { jsResult, err = page.Eval(runCode) if err != nil { return nil, fmt.Errorf("failed to run axe-core: %w", err) } } // Parse the results resultsJSON := jsResult.Value.Str() var results AxeResults err = json.Unmarshal([]byte(resultsJSON), &results) if err != nil { return nil, fmt.Errorf("failed to parse axe-core results: %w", err) } d.debugLog("Successfully ran axe-core tests for tab: %s (found %d violations)", tabID, len(results.Violations)) return &results, nil } // ContrastCheckResult represents the result of contrast checking for text elements type ContrastCheckResult struct { TotalElements int `json:"total_elements"` PassedAA int `json:"passed_aa"` PassedAAA int `json:"passed_aaa"` FailedAA int `json:"failed_aa"` FailedAAA int `json:"failed_aaa"` UnableToCheck int `json:"unable_to_check"` Elements []ContrastCheckElement `json:"elements"` } // ContrastCheckElement represents a single element's contrast check type ContrastCheckElement struct { Selector string `json:"selector"` Text string `json:"text"` ForegroundColor string `json:"foreground_color"` BackgroundColor string `json:"background_color"` ContrastRatio float64 `json:"contrast_ratio"` FontSize string `json:"font_size"` FontWeight string `json:"font_weight"` IsLargeText bool `json:"is_large_text"` PassesAA bool `json:"passes_aa"` PassesAAA bool `json:"passes_aaa"` RequiredAA float64 `json:"required_aa"` RequiredAAA float64 `json:"required_aaa"` Error string `json:"error,omitempty"` } // checkContrast checks color contrast for text elements on the page func (d *Daemon) checkContrast(tabID string, selector string, timeout int) (*ContrastCheckResult, error) { d.debugLog("Checking contrast for tab: %s, selector: %s", tabID, selector) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Default selector to check all text elements if selector == "" { selector = "p, h1, h2, h3, h4, h5, h6, a, button, span, div, li, td, th, label, input, textarea" } // JavaScript code to check contrast for all matching elements jsCode := fmt.Sprintf(`() => { // Helper function to parse RGB color function parseColor(colorStr) { const rgb = colorStr.match(/\d+/g); if (!rgb || rgb.length < 3) return null; return { r: parseInt(rgb[0]), g: parseInt(rgb[1]), b: parseInt(rgb[2]), a: rgb.length > 3 ? parseFloat(rgb[3]) : 1 }; } // Helper function to calculate relative luminance function getLuminance(r, g, b) { const rsRGB = r / 255; const gsRGB = g / 255; const bsRGB = b / 255; const r2 = rsRGB <= 0.03928 ? rsRGB / 12.92 : Math.pow((rsRGB + 0.055) / 1.055, 2.4); const g2 = gsRGB <= 0.03928 ? gsRGB / 12.92 : Math.pow((gsRGB + 0.055) / 1.055, 2.4); const b2 = bsRGB <= 0.03928 ? bsRGB / 12.92 : Math.pow((bsRGB + 0.055) / 1.055, 2.4); return 0.2126 * r2 + 0.7152 * g2 + 0.0722 * b2; } // Helper function to calculate contrast ratio function getContrastRatio(fg, bg) { const l1 = getLuminance(fg.r, fg.g, fg.b); const l2 = getLuminance(bg.r, bg.g, bg.b); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } // Helper function to get effective background color function getEffectiveBackground(element) { let current = element; while (current && current !== document.body.parentElement) { const style = window.getComputedStyle(current); const bgColor = style.backgroundColor; const parsed = parseColor(bgColor); if (parsed && parsed.a > 0) { // Check if it's not transparent if (!(parsed.r === 0 && parsed.g === 0 && parsed.b === 0 && parsed.a === 0)) { return bgColor; } } current = current.parentElement; } return 'rgb(255, 255, 255)'; // Default to white } // Helper function to check if text is large function isLargeText(fontSize, fontWeight) { const size = parseFloat(fontSize); const weight = parseInt(fontWeight) || 400; // 18pt (24px) or larger, or 14pt (18.66px) bold or larger return size >= 24 || (size >= 18.66 && weight >= 700); } // Get all matching elements const elements = document.querySelectorAll('%s'); const results = []; elements.forEach((element, index) => { try { // Skip if element has no text content const text = element.textContent.trim(); if (!text || text.length === 0) return; // Get computed styles const style = window.getComputedStyle(element); const fgColor = style.color; const bgColor = getEffectiveBackground(element); const fontSize = style.fontSize; const fontWeight = style.fontWeight; // Parse colors const fg = parseColor(fgColor); const bg = parseColor(bgColor); if (!fg || !bg) { results.push({ selector: '%s:nth-of-type(' + (index + 1) + ')', text: text.substring(0, 100), error: 'Unable to parse colors' }); return; } // Calculate contrast ratio const ratio = getContrastRatio(fg, bg); const large = isLargeText(fontSize, fontWeight); // WCAG requirements const requiredAA = large ? 3.0 : 4.5; const requiredAAA = large ? 4.5 : 7.0; results.push({ selector: '%s:nth-of-type(' + (index + 1) + ')', text: text.substring(0, 100), foreground_color: fgColor, background_color: bgColor, contrast_ratio: Math.round(ratio * 100) / 100, font_size: fontSize, font_weight: fontWeight, is_large_text: large, passes_aa: ratio >= requiredAA, passes_aaa: ratio >= requiredAAA, required_aa: requiredAA, required_aaa: requiredAAA }); } catch (e) { results.push({ selector: '%s:nth-of-type(' + (index + 1) + ')', text: element.textContent.trim().substring(0, 100), error: e.message }); } }); return JSON.stringify(results); }`, selector, selector, selector, selector) var jsResult *proto.RuntimeRemoteObject if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() done := make(chan struct { result *proto.RuntimeRemoteObject err error }, 1) go func() { result, err := page.Eval(jsCode) done <- struct { result *proto.RuntimeRemoteObject err error }{result, err} }() select { case res := <-done: if res.err != nil { return nil, fmt.Errorf("failed to check contrast: %w", res.err) } jsResult = res.result case <-ctx.Done(): return nil, fmt.Errorf("contrast check timed out after %d seconds", timeout) } } else { jsResult, err = page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to check contrast: %w", err) } } // Parse the results resultsJSON := jsResult.Value.Str() var elements []ContrastCheckElement err = json.Unmarshal([]byte(resultsJSON), &elements) if err != nil { return nil, fmt.Errorf("failed to parse contrast results: %w", err) } // Calculate summary statistics result := &ContrastCheckResult{ TotalElements: len(elements), Elements: elements, } for _, elem := range elements { if elem.Error != "" { result.UnableToCheck++ } else { if elem.PassesAA { result.PassedAA++ } else { result.FailedAA++ } if elem.PassesAAA { result.PassedAAA++ } else { result.FailedAAA++ } } } d.debugLog("Successfully checked contrast for tab: %s (checked %d elements)", tabID, len(elements)) return result, nil } // GradientContrastResult represents the result of gradient contrast checking type GradientContrastResult struct { Selector string `json:"selector"` TextColor string `json:"text_color"` DarkestBgColor string `json:"darkest_bg_color"` LightestBgColor string `json:"lightest_bg_color"` WorstContrast float64 `json:"worst_contrast"` BestContrast float64 `json:"best_contrast"` PassesAA bool `json:"passes_aa"` PassesAAA bool `json:"passes_aaa"` RequiredAA float64 `json:"required_aa"` RequiredAAA float64 `json:"required_aaa"` IsLargeText bool `json:"is_large_text"` SamplePoints int `json:"sample_points"` Error string `json:"error,omitempty"` } // checkGradientContrast checks color contrast for text on gradient backgrounds using ImageMagick func (d *Daemon) checkGradientContrast(tabID string, selector string, timeout int) (*GradientContrastResult, error) { d.debugLog("Checking gradient contrast for tab: %s, selector: %s", tabID, selector) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Take screenshot of the element screenshotPath := fmt.Sprintf("/tmp/gradient-contrast-%d.png", time.Now().UnixNano()) defer os.Remove(screenshotPath) // Clean up after we're done err = d.screenshotElement(tabID, selector, screenshotPath, timeout) if err != nil { return nil, fmt.Errorf("failed to take element screenshot: %v", err) } // Get text color and font size from computed styles jsCode := fmt.Sprintf(`() => { const element = document.querySelector('%s'); if (!element) { return JSON.stringify({error: 'Element not found'}); } const style = window.getComputedStyle(element); const fontSize = parseFloat(style.fontSize); const fontWeight = parseInt(style.fontWeight) || 400; // Determine if this is large text (18pt+ or 14pt+ bold) // 1pt = 1.333px, so 18pt = 24px, 14pt = 18.67px const isLargeText = fontSize >= 24 || (fontSize >= 18.67 && fontWeight >= 700); return JSON.stringify({ color: style.color, fontSize: fontSize, fontWeight: fontWeight, isLargeText: isLargeText }); }`, selector) jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to get element styles: %v", err) } var styleInfo struct { Color string `json:"color"` FontSize float64 `json:"fontSize"` FontWeight int `json:"fontWeight"` IsLargeText bool `json:"isLargeText"` Error string `json:"error,omitempty"` } err = json.Unmarshal([]byte(jsResult.Value.Str()), &styleInfo) if err != nil { return nil, fmt.Errorf("failed to parse style info: %v", err) } if styleInfo.Error != "" { return &GradientContrastResult{ Selector: selector, Error: styleInfo.Error, }, nil } // Parse text color textColor, err := d.parseRGBColor(styleInfo.Color) if err != nil { return &GradientContrastResult{ Selector: selector, Error: fmt.Sprintf("Failed to parse text color: %v", err), }, nil } // Use ImageMagick to sample colors from the background // Resize to 10x10 to get 100 sample points cmd := exec.Command("convert", screenshotPath, "-resize", "10x10!", "-depth", "8", "txt:-") output, err := cmd.CombinedOutput() if err != nil { return &GradientContrastResult{ Selector: selector, Error: fmt.Sprintf("ImageMagick failed: %v - %s", err, string(output)), }, nil } // Parse ImageMagick output to extract colors colors, err := d.parseImageMagickColors(string(output)) if err != nil { return &GradientContrastResult{ Selector: selector, Error: fmt.Sprintf("Failed to parse colors: %v", err), }, nil } if len(colors) == 0 { return &GradientContrastResult{ Selector: selector, Error: "No colors found in image", }, nil } // Calculate contrast ratios against all sampled colors var worstContrast, bestContrast float64 var darkestColor, lightestColor map[string]int worstContrast = 21.0 // Maximum possible contrast bestContrast = 1.0 // Minimum possible contrast for _, bgColor := range colors { contrast := d.calculateContrastRatio(textColor, bgColor) if contrast < worstContrast { worstContrast = contrast darkestColor = bgColor } if contrast > bestContrast { bestContrast = contrast lightestColor = bgColor } } // Determine required contrast ratios based on text size requiredAA := 4.5 requiredAAA := 7.0 if styleInfo.IsLargeText { requiredAA = 3.0 requiredAAA = 4.5 } result := &GradientContrastResult{ Selector: selector, TextColor: styleInfo.Color, DarkestBgColor: fmt.Sprintf("rgb(%d, %d, %d)", darkestColor["r"], darkestColor["g"], darkestColor["b"]), LightestBgColor: fmt.Sprintf("rgb(%d, %d, %d)", lightestColor["r"], lightestColor["g"], lightestColor["b"]), WorstContrast: worstContrast, BestContrast: bestContrast, PassesAA: worstContrast >= requiredAA, PassesAAA: worstContrast >= requiredAAA, RequiredAA: requiredAA, RequiredAAA: requiredAAA, IsLargeText: styleInfo.IsLargeText, SamplePoints: len(colors), } d.debugLog("Successfully checked gradient contrast for tab: %s, selector: %s (worst: %.2f, best: %.2f)", tabID, selector, worstContrast, bestContrast) return result, nil } // parseRGBColor parses an RGB color string into a map func (d *Daemon) parseRGBColor(colorStr string) (map[string]int, error) { // Match rgb(r, g, b) or rgba(r, g, b, a) re := regexp.MustCompile(`rgba?\((\d+),\s*(\d+),\s*(\d+)`) matches := re.FindStringSubmatch(colorStr) if len(matches) < 4 { return nil, fmt.Errorf("invalid color format: %s", colorStr) } r, _ := strconv.Atoi(matches[1]) g, _ := strconv.Atoi(matches[2]) b, _ := strconv.Atoi(matches[3]) return map[string]int{"r": r, "g": g, "b": b}, nil } // parseImageMagickColors parses ImageMagick txt output to extract RGB colors func (d *Daemon) parseImageMagickColors(output string) ([]map[string]int, error) { colors := []map[string]int{} // ImageMagick txt format: "0,0: (255,255,255) #FFFFFF srgb(255,255,255)" re := regexp.MustCompile(`srgb\((\d+),(\d+),(\d+)\)`) lines := strings.Split(output, "\n") for _, line := range lines { matches := re.FindStringSubmatch(line) if len(matches) == 4 { r, _ := strconv.Atoi(matches[1]) g, _ := strconv.Atoi(matches[2]) b, _ := strconv.Atoi(matches[3]) colors = append(colors, map[string]int{"r": r, "g": g, "b": b}) } } return colors, nil } // calculateContrastRatio calculates WCAG contrast ratio between two colors func (d *Daemon) calculateContrastRatio(color1, color2 map[string]int) float64 { l1 := d.getRelativeLuminance(color1["r"], color1["g"], color1["b"]) l2 := d.getRelativeLuminance(color2["r"], color2["g"], color2["b"]) lighter := math.Max(l1, l2) darker := math.Min(l1, l2) return (lighter + 0.05) / (darker + 0.05) } // getRelativeLuminance calculates relative luminance for WCAG contrast func (d *Daemon) getRelativeLuminance(r, g, b int) float64 { rsRGB := float64(r) / 255.0 gsRGB := float64(g) / 255.0 bsRGB := float64(b) / 255.0 rLinear := rsRGB if rsRGB <= 0.03928 { rLinear = rsRGB / 12.92 } else { rLinear = math.Pow((rsRGB+0.055)/1.055, 2.4) } gLinear := gsRGB if gsRGB <= 0.03928 { gLinear = gsRGB / 12.92 } else { gLinear = math.Pow((gsRGB+0.055)/1.055, 2.4) } bLinear := bsRGB if bsRGB <= 0.03928 { bLinear = bsRGB / 12.92 } else { bLinear = math.Pow((bsRGB+0.055)/1.055, 2.4) } return 0.2126*rLinear + 0.7152*gLinear + 0.0722*bLinear } // MediaValidationResult represents the result of time-based media validation type MediaValidationResult struct { Videos []MediaElement `json:"videos"` Audios []MediaElement `json:"audios"` EmbeddedPlayers []MediaElement `json:"embedded_players"` TranscriptLinks []string `json:"transcript_links"` TotalViolations int `json:"total_violations"` CriticalViolations int `json:"critical_violations"` Warnings int `json:"warnings"` } // MediaElement represents a video or audio element type MediaElement struct { Type string `json:"type"` // "video", "audio", "youtube", "vimeo" Src string `json:"src"` HasCaptions bool `json:"has_captions"` HasDescriptions bool `json:"has_descriptions"` HasControls bool `json:"has_controls"` Autoplay bool `json:"autoplay"` CaptionTracks []Track `json:"caption_tracks"` DescriptionTracks []Track `json:"description_tracks"` Violations []string `json:"violations"` Warnings []string `json:"warnings"` } // Track represents a text track (captions, descriptions, etc.) type Track struct { Kind string `json:"kind"` Src string `json:"src"` Srclang string `json:"srclang"` Label string `json:"label"` Accessible bool `json:"accessible"` } // validateMedia checks for video/audio captions, descriptions, and transcripts func (d *Daemon) validateMedia(tabID string, timeout int) (*MediaValidationResult, error) { d.debugLog("Validating media for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // JavaScript code to inventory all media elements jsCode := `() => { const result = { videos: [], audios: [], embeddedPlayers: [], transcriptLinks: [] }; // Find all video elements document.querySelectorAll('video').forEach(video => { const videoData = { type: 'video', src: video.src || video.currentSrc || 'inline', hasCaptions: false, hasDescriptions: false, hasControls: video.hasAttribute('controls'), autoplay: video.hasAttribute('autoplay'), captionTracks: [], descriptionTracks: [] }; // Check for text tracks video.querySelectorAll('track').forEach(track => { const trackData = { kind: track.kind, src: track.src, srclang: track.srclang || '', label: track.label || '' }; if (track.kind === 'captions' || track.kind === 'subtitles') { videoData.hasCaptions = true; videoData.captionTracks.push(trackData); } else if (track.kind === 'descriptions') { videoData.hasDescriptions = true; videoData.descriptionTracks.push(trackData); } }); result.videos.push(videoData); }); // Find all audio elements document.querySelectorAll('audio').forEach(audio => { const audioData = { type: 'audio', src: audio.src || audio.currentSrc || 'inline', hasControls: audio.hasAttribute('controls'), autoplay: audio.hasAttribute('autoplay') }; result.audios.push(audioData); }); // Find embedded players (YouTube, Vimeo) document.querySelectorAll('iframe[src*="youtube"], iframe[src*="vimeo"]').forEach(iframe => { const playerData = { type: iframe.src.includes('youtube') ? 'youtube' : 'vimeo', src: iframe.src }; result.embeddedPlayers.push(playerData); }); // Find transcript links // Note: :contains() is not a valid CSS selector in querySelectorAll // We need to check all links and filter by text content const transcriptPatterns = ['transcript', 'captions', 'subtitles']; document.querySelectorAll('a').forEach(link => { const text = link.textContent.toLowerCase(); const href = link.href.toLowerCase(); if (transcriptPatterns.some(pattern => text.includes(pattern) || href.includes(pattern))) { if (!result.transcriptLinks.includes(link.href)) { result.transcriptLinks.push(link.href); } } }); return JSON.stringify(result); }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to execute media validation: %v", err) } // Parse the JavaScript result var mediaData struct { Videos []MediaElement `json:"videos"` Audios []MediaElement `json:"audios"` EmbeddedPlayers []MediaElement `json:"embedded_players"` TranscriptLinks []string `json:"transcript_links"` } err = json.Unmarshal([]byte(jsResult.Value.Str()), &mediaData) if err != nil { return nil, fmt.Errorf("failed to parse media data: %v", err) } // Validate each video element totalViolations := 0 criticalViolations := 0 warnings := 0 for i := range mediaData.Videos { video := &mediaData.Videos[i] // Check for captions (WCAG 1.2.2 - Level A - CRITICAL) if !video.HasCaptions { video.Violations = append(video.Violations, "CRITICAL: Missing captions (WCAG 1.2.2 Level A)") criticalViolations++ totalViolations++ } // Check for audio descriptions (WCAG 1.2.5 - Level AA) if !video.HasDescriptions { video.Warnings = append(video.Warnings, "WARNING: Missing audio descriptions (WCAG 1.2.5 Level AA)") warnings++ } // Check for controls if !video.HasControls { video.Warnings = append(video.Warnings, "WARNING: No controls attribute - users cannot pause/adjust") warnings++ } // Check for autoplay (WCAG 1.4.2 - Level A) if video.Autoplay { video.Warnings = append(video.Warnings, "WARNING: Video autoplays - may violate WCAG 1.4.2 if >3 seconds") warnings++ } // Validate caption track accessibility for j := range video.CaptionTracks { track := &video.CaptionTracks[j] accessible, err := d.checkTrackAccessibility(tabID, track.Src, timeout) track.Accessible = accessible if err != nil || !accessible { video.Violations = append(video.Violations, fmt.Sprintf("Caption file not accessible: %s", track.Src)) totalViolations++ } } // Validate description track accessibility for j := range video.DescriptionTracks { track := &video.DescriptionTracks[j] accessible, err := d.checkTrackAccessibility(tabID, track.Src, timeout) track.Accessible = accessible if err != nil || !accessible { video.Warnings = append(video.Warnings, fmt.Sprintf("Description file not accessible: %s", track.Src)) warnings++ } } } // Validate audio elements for i := range mediaData.Audios { audio := &mediaData.Audios[i] // Check for controls if !audio.HasControls { audio.Warnings = append(audio.Warnings, "WARNING: No controls attribute - users cannot pause/adjust") warnings++ } // Check for autoplay (WCAG 1.4.2 - Level A) if audio.Autoplay { audio.Warnings = append(audio.Warnings, "WARNING: Audio autoplays - may violate WCAG 1.4.2 if >3 seconds") warnings++ } } result := &MediaValidationResult{ Videos: mediaData.Videos, Audios: mediaData.Audios, EmbeddedPlayers: mediaData.EmbeddedPlayers, TranscriptLinks: mediaData.TranscriptLinks, TotalViolations: totalViolations, CriticalViolations: criticalViolations, Warnings: warnings, } d.debugLog("Successfully validated media for tab: %s (videos: %d, audios: %d, violations: %d)", tabID, len(mediaData.Videos), len(mediaData.Audios), totalViolations) return result, nil } // checkTrackAccessibility checks if a track file is accessible func (d *Daemon) checkTrackAccessibility(tabID, trackSrc string, timeout int) (bool, error) { if trackSrc == "" { return false, fmt.Errorf("empty track source") } // Use JavaScript to fetch the track file jsCode := fmt.Sprintf(`async () => { try { const response = await fetch('%s'); return response.ok; } catch (error) { return false; } }`, trackSrc) page, err := d.getTab(tabID) if err != nil { return false, err } jsResult, err := page.Eval(jsCode) if err != nil { return false, err } return jsResult.Value.Bool(), nil } // HoverFocusTestResult represents the result of hover/focus content testing type HoverFocusTestResult struct { TotalElements int `json:"total_elements"` ElementsWithIssues int `json:"elements_with_issues"` PassedElements int `json:"passed_elements"` Issues []HoverFocusIssue `json:"issues"` TestedElements []HoverFocusElement `json:"tested_elements"` } // HoverFocusElement represents an element that shows content on hover/focus type HoverFocusElement struct { Selector string `json:"selector"` Type string `json:"type"` // "tooltip", "dropdown", "popover", "custom" Dismissible bool `json:"dismissible"` Hoverable bool `json:"hoverable"` Persistent bool `json:"persistent"` PassesWCAG bool `json:"passes_wcag"` Violations []string `json:"violations"` } // HoverFocusIssue represents a specific issue with hover/focus content type HoverFocusIssue struct { Selector string `json:"selector"` Type string `json:"type"` // "not_dismissible", "not_hoverable", "not_persistent" Severity string `json:"severity"` // "critical", "serious", "moderate" Description string `json:"description"` WCAG string `json:"wcag"` // "1.4.13" } // testHoverFocusContent tests WCAG 1.4.13 compliance for content on hover or focus func (d *Daemon) testHoverFocusContent(tabID string, timeout int) (*HoverFocusTestResult, error) { d.debugLog("Testing hover/focus content for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // JavaScript code to find elements that show content on hover/focus jsCode := `() => { const result = { elements: [] }; // Common selectors for elements that show content on hover/focus const selectors = [ '[title]', // Elements with tooltips '[aria-describedby]', // Elements with descriptions '[data-tooltip]', // Custom tooltip attributes '.tooltip-trigger', // Common tooltip classes '.has-tooltip', '[role="tooltip"]', 'button[aria-haspopup]', // Buttons with popups 'a[aria-haspopup]', // Links with popups '[aria-expanded]', // Expandable elements '.dropdown-toggle', // Dropdown triggers '.popover-trigger' // Popover triggers ]; // Find all potential hover/focus elements const foundElements = new Set(); selectors.forEach(selector => { try { document.querySelectorAll(selector).forEach(el => { if (el.offsetParent !== null) { // Only visible elements foundElements.add(el); } }); } catch (e) { // Ignore invalid selectors } }); // Test each element foundElements.forEach((element, index) => { const elementData = { selector: '', type: 'custom', hasTitle: element.hasAttribute('title'), hasAriaDescribedby: element.hasAttribute('aria-describedby'), hasAriaHaspopup: element.hasAttribute('aria-haspopup'), hasAriaExpanded: element.hasAttribute('aria-expanded'), role: element.getAttribute('role') || '', tagName: element.tagName.toLowerCase() }; // Generate a selector if (element.id) { elementData.selector = '#' + element.id; } else if (element.className) { const classes = element.className.split(' ').filter(c => c).slice(0, 2).join('.'); elementData.selector = element.tagName.toLowerCase() + '.' + classes; } else { elementData.selector = element.tagName.toLowerCase() + ':nth-of-type(' + (index + 1) + ')'; } // Determine type if (element.hasAttribute('title')) { elementData.type = 'tooltip'; } else if (element.classList.contains('dropdown-toggle') || element.hasAttribute('aria-haspopup')) { elementData.type = 'dropdown'; } else if (element.classList.contains('popover-trigger')) { elementData.type = 'popover'; } result.elements.push(elementData); }); return JSON.stringify(result); }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to find hover/focus elements: %v", err) } // Parse the JavaScript result var elementsData struct { Elements []struct { Selector string `json:"selector"` Type string `json:"type"` HasTitle bool `json:"hasTitle"` HasAriaDescribedby bool `json:"hasAriaDescribedby"` HasAriaHaspopup bool `json:"hasAriaHaspopup"` HasAriaExpanded bool `json:"hasAriaExpanded"` Role string `json:"role"` TagName string `json:"tagName"` } `json:"elements"` } err = json.Unmarshal([]byte(jsResult.Value.Str()), &elementsData) if err != nil { return nil, fmt.Errorf("failed to parse elements data: %v", err) } result := &HoverFocusTestResult{ TotalElements: len(elementsData.Elements), TestedElements: make([]HoverFocusElement, 0), Issues: make([]HoverFocusIssue, 0), } // Test each element for WCAG 1.4.13 compliance for _, elem := range elementsData.Elements { testedElement := HoverFocusElement{ Selector: elem.Selector, Type: elem.Type, Dismissible: true, // Assume true unless proven false Hoverable: true, // Assume true unless proven false Persistent: true, // Assume true unless proven false PassesWCAG: true, Violations: make([]string, 0), } // For tooltips (title attribute), check if they're dismissible if elem.HasTitle { // Native title tooltips are NOT dismissible with Escape key testedElement.Dismissible = false testedElement.PassesWCAG = false testedElement.Violations = append(testedElement.Violations, "Native title attribute tooltip is not dismissible with Escape key (WCAG 1.4.13)") result.Issues = append(result.Issues, HoverFocusIssue{ Selector: elem.Selector, Type: "not_dismissible", Severity: "serious", Description: "Native title attribute creates non-dismissible tooltip", WCAG: "1.4.13", }) } // For elements with aria-describedby, check if the description is accessible if elem.HasAriaDescribedby { // These are usually properly implemented, but we flag for manual review testedElement.Violations = append(testedElement.Violations, "Manual review required: Verify aria-describedby content is dismissible, hoverable, and persistent") } // For dropdowns and popovers, we can't fully test without interaction // Flag for manual review if elem.HasAriaHaspopup || elem.HasAriaExpanded { testedElement.Violations = append(testedElement.Violations, "Manual review required: Test dropdown/popover for dismissibility, hoverability, and persistence") } if !testedElement.PassesWCAG { result.ElementsWithIssues++ } else { result.PassedElements++ } result.TestedElements = append(result.TestedElements, testedElement) } d.debugLog("Successfully tested hover/focus content for tab: %s (elements: %d, issues: %d)", tabID, result.TotalElements, result.ElementsWithIssues) return result, nil } // TextInImagesResult represents the result of text-in-images detection type TextInImagesResult struct { TotalImages int `json:"total_images"` ImagesWithText int `json:"images_with_text"` ImagesWithoutText int `json:"images_without_text"` Violations int `json:"violations"` Warnings int `json:"warnings"` Images []ImageTextAnalysis `json:"images"` } // ImageTextAnalysis represents OCR analysis of a single image type ImageTextAnalysis struct { Src string `json:"src"` Alt string `json:"alt"` HasAlt bool `json:"has_alt"` DetectedText string `json:"detected_text"` TextLength int `json:"text_length"` Confidence float64 `json:"confidence"` IsViolation bool `json:"is_violation"` ViolationType string `json:"violation_type"` // "missing_alt", "insufficient_alt", "decorative_with_text" Recommendation string `json:"recommendation"` } // detectTextInImages uses Tesseract OCR to detect text in images func (d *Daemon) detectTextInImages(tabID string, timeout int) (*TextInImagesResult, error) { d.debugLog("Detecting text in images for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // JavaScript code to find all images jsCode := `() => { const result = { images: [] }; // Find all img elements document.querySelectorAll('img').forEach((img, index) => { // Only process visible images if (img.offsetParent !== null && img.complete && img.naturalWidth > 0) { const imageData = { src: img.src || img.currentSrc || '', alt: img.alt || '', hasAlt: img.hasAttribute('alt'), width: img.naturalWidth, height: img.naturalHeight, index: index }; // Skip very small images (likely icons/decorative) if (imageData.width >= 50 && imageData.height >= 50) { result.images.push(imageData); } } }); return JSON.stringify(result); }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to find images: %v", err) } // Parse the JavaScript result var imagesData struct { Images []struct { Src string `json:"src"` Alt string `json:"alt"` HasAlt bool `json:"hasAlt"` Width int `json:"width"` Height int `json:"height"` Index int `json:"index"` } `json:"images"` } err = json.Unmarshal([]byte(jsResult.Value.Str()), &imagesData) if err != nil { return nil, fmt.Errorf("failed to parse images data: %v", err) } result := &TextInImagesResult{ TotalImages: len(imagesData.Images), Images: make([]ImageTextAnalysis, 0), } // Process each image with OCR for _, img := range imagesData.Images { analysis := ImageTextAnalysis{ Src: img.Src, Alt: img.Alt, HasAlt: img.HasAlt, } // Download image and run OCR detectedText, confidence, err := d.runOCROnImage(img.Src, timeout) if err != nil { d.debugLog("Failed to run OCR on image %s: %v", img.Src, err) // Continue with other images continue } analysis.DetectedText = detectedText analysis.TextLength = len(detectedText) analysis.Confidence = confidence // Determine if this is a violation if analysis.TextLength > 10 { // Significant text detected result.ImagesWithText++ if !analysis.HasAlt || analysis.Alt == "" { // CRITICAL: Image has text but no alt text analysis.IsViolation = true analysis.ViolationType = "missing_alt" analysis.Recommendation = "Add alt text that includes the text content: \"" + detectedText + "\"" result.Violations++ } else if len(analysis.Alt) < analysis.TextLength/2 { // WARNING: Alt text seems insufficient for amount of text analysis.IsViolation = true analysis.ViolationType = "insufficient_alt" analysis.Recommendation = "Alt text may be insufficient. Detected text: \"" + detectedText + "\"" result.Warnings++ } else { // Alt text exists and seems adequate analysis.IsViolation = false analysis.Recommendation = "Alt text present - verify it includes the text content" } } else { result.ImagesWithoutText++ } result.Images = append(result.Images, analysis) } d.debugLog("Successfully detected text in images for tab: %s (total: %d, with text: %d, violations: %d)", tabID, result.TotalImages, result.ImagesWithText, result.Violations) return result, nil } // runOCROnImage downloads an image and runs Tesseract OCR on it func (d *Daemon) runOCROnImage(imageSrc string, timeout int) (string, float64, error) { // Create temporary file for image tmpFile, err := os.CreateTemp("", "ocr-image-*.png") if err != nil { return "", 0, fmt.Errorf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) tmpFile.Close() // Download image (simple approach - could be enhanced) // For now, we'll use a simple HTTP GET // In production, this should handle data URLs, relative URLs, etc. // Skip data URLs for now if strings.HasPrefix(imageSrc, "data:") { return "", 0, fmt.Errorf("data URLs not supported yet") } // Use curl to download (more reliable than Go's http.Get for various scenarios) downloadCmd := exec.Command("curl", "-s", "-L", "-o", tmpFile.Name(), imageSrc) downloadCmd.Stdout = nil downloadCmd.Stderr = nil err = downloadCmd.Run() if err != nil { return "", 0, fmt.Errorf("failed to download image: %v", err) } // Run Tesseract OCR outputFile := tmpFile.Name() + "-output" defer os.Remove(outputFile + ".txt") tesseractCmd := exec.Command("tesseract", tmpFile.Name(), outputFile, "--psm", "6") tesseractCmd.Stdout = nil tesseractCmd.Stderr = nil err = tesseractCmd.Run() if err != nil { return "", 0, fmt.Errorf("failed to run tesseract: %v", err) } // Read OCR output ocrOutput, err := os.ReadFile(outputFile + ".txt") if err != nil { return "", 0, fmt.Errorf("failed to read OCR output: %v", err) } // Clean up the text text := strings.TrimSpace(string(ocrOutput)) // Calculate confidence (simplified - Tesseract can provide detailed confidence) // For now, we'll use a simple heuristic based on text length and character variety confidence := 0.8 // Default confidence if len(text) > 0 { confidence = 0.9 } return text, confidence, nil } // CrossPageConsistencyResult represents the result of cross-page consistency checking type CrossPageConsistencyResult struct { PagesAnalyzed int `json:"pages_analyzed"` ConsistencyIssues int `json:"consistency_issues"` NavigationIssues int `json:"navigation_issues"` StructureIssues int `json:"structure_issues"` Pages []PageConsistencyAnalysis `json:"pages"` CommonNavigation []string `json:"common_navigation"` InconsistentPages []string `json:"inconsistent_pages"` } // PageConsistencyAnalysis represents consistency analysis of a single page type PageConsistencyAnalysis struct { URL string `json:"url"` Title string `json:"title"` HasHeader bool `json:"has_header"` HasFooter bool `json:"has_footer"` HasNavigation bool `json:"has_navigation"` NavigationLinks []string `json:"navigation_links"` MainLandmarks int `json:"main_landmarks"` HeaderLandmarks int `json:"header_landmarks"` FooterLandmarks int `json:"footer_landmarks"` NavigationLandmarks int `json:"navigation_landmarks"` Issues []string `json:"issues"` } // checkCrossPageConsistency analyzes multiple pages for consistency func (d *Daemon) checkCrossPageConsistency(tabID string, urls []string, timeout int) (*CrossPageConsistencyResult, error) { d.debugLog("Checking cross-page consistency for %d URLs", len(urls)) if len(urls) == 0 { return nil, fmt.Errorf("no URLs provided for consistency check") } result := &CrossPageConsistencyResult{ Pages: make([]PageConsistencyAnalysis, 0), CommonNavigation: make([]string, 0), InconsistentPages: make([]string, 0), } // Analyze each page for _, url := range urls { pageAnalysis, err := d.analyzePageConsistency(tabID, url, timeout) if err != nil { d.debugLog("Failed to analyze page %s: %v", url, err) continue } result.Pages = append(result.Pages, *pageAnalysis) result.PagesAnalyzed++ } // Find common navigation elements if len(result.Pages) > 1 { navMap := make(map[string]int) for _, page := range result.Pages { for _, link := range page.NavigationLinks { navMap[link]++ } } // Links that appear on all pages are "common navigation" for link, count := range navMap { if count == len(result.Pages) { result.CommonNavigation = append(result.CommonNavigation, link) } } // Check for inconsistencies for _, page := range result.Pages { issues := 0 // Check if page has all common navigation for _, commonLink := range result.CommonNavigation { found := false for _, pageLink := range page.NavigationLinks { if pageLink == commonLink { found = true break } } if !found { page.Issues = append(page.Issues, "Missing common navigation link: "+commonLink) issues++ } } // Check for multiple main landmarks (should be 1) if page.MainLandmarks != 1 { page.Issues = append(page.Issues, fmt.Sprintf("Should have exactly 1 main landmark, found %d", page.MainLandmarks)) issues++ result.StructureIssues++ } // Check for header/footer presence if !page.HasHeader { page.Issues = append(page.Issues, "Missing header landmark") issues++ result.StructureIssues++ } if !page.HasFooter { page.Issues = append(page.Issues, "Missing footer landmark") issues++ result.StructureIssues++ } if !page.HasNavigation { page.Issues = append(page.Issues, "Missing navigation landmark") issues++ result.NavigationIssues++ } if issues > 0 { result.InconsistentPages = append(result.InconsistentPages, page.URL) result.ConsistencyIssues += issues } } } d.debugLog("Successfully checked cross-page consistency: %d pages, %d issues", result.PagesAnalyzed, result.ConsistencyIssues) return result, nil } // analyzePageConsistency analyzes a single page for consistency elements func (d *Daemon) analyzePageConsistency(tabID, url string, timeout int) (*PageConsistencyAnalysis, error) { page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Navigate to URL err = page.Navigate(url) if err != nil { return nil, fmt.Errorf("failed to navigate to %s: %v", url, err) } // Wait for page to load err = page.WaitLoad() if err != nil { return nil, fmt.Errorf("failed to wait for page load: %v", err) } // JavaScript code to analyze page structure jsCode := `() => { const result = { url: window.location.href, title: document.title, hasHeader: false, hasFooter: false, hasNavigation: false, navigationLinks: [], mainLandmarks: 0, headerLandmarks: 0, footerLandmarks: 0, navigationLandmarks: 0 }; // Count landmarks result.mainLandmarks = document.querySelectorAll('main, [role="main"]').length; result.headerLandmarks = document.querySelectorAll('header, [role="banner"]').length; result.footerLandmarks = document.querySelectorAll('footer, [role="contentinfo"]').length; result.navigationLandmarks = document.querySelectorAll('nav, [role="navigation"]').length; result.hasHeader = result.headerLandmarks > 0; result.hasFooter = result.footerLandmarks > 0; result.hasNavigation = result.navigationLandmarks > 0; // Extract navigation links document.querySelectorAll('nav a, [role="navigation"] a').forEach(link => { if (link.href && !link.href.startsWith('javascript:')) { result.navigationLinks.push(link.textContent.trim()); } }); return JSON.stringify(result); }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to analyze page structure: %v", err) } // Parse the JavaScript result var analysis PageConsistencyAnalysis err = json.Unmarshal([]byte(jsResult.Value.Str()), &analysis) if err != nil { return nil, fmt.Errorf("failed to parse page analysis: %v", err) } analysis.Issues = make([]string, 0) return &analysis, nil } // SensoryCharacteristicsResult represents the result of sensory characteristics detection type SensoryCharacteristicsResult struct { TotalElements int `json:"total_elements"` ElementsWithIssues int `json:"elements_with_issues"` Violations int `json:"violations"` Warnings int `json:"warnings"` Elements []SensoryCharacteristicsElement `json:"elements"` PatternMatches map[string]int `json:"pattern_matches"` } // SensoryCharacteristicsElement represents an element with potential sensory-only instructions type SensoryCharacteristicsElement struct { TagName string `json:"tag_name"` Text string `json:"text"` MatchedPatterns []string `json:"matched_patterns"` Severity string `json:"severity"` // "violation", "warning" Recommendation string `json:"recommendation"` } // detectSensoryCharacteristics detects instructions that rely only on sensory characteristics func (d *Daemon) detectSensoryCharacteristics(tabID string, timeout int) (*SensoryCharacteristicsResult, error) { d.debugLog("Detecting sensory characteristics for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Define sensory characteristic patterns (WCAG 1.3.3) // These patterns indicate instructions that rely solely on sensory characteristics sensoryPatterns := map[string]string{ "color_only": `(?i)\b(red|green|blue|yellow|orange|purple|pink|black|white|gray|grey)\s+(button|link|icon|text|box|area|section|field|item)`, "shape_only": `(?i)\b(round|square|circular|rectangular|triangle|diamond|star)\s+(button|link|icon|box|area|section|item)`, "size_only": `(?i)\b(large|small|big|tiny|huge)\s+(button|link|icon|text|box|area|section|field|item)`, "location_visual": `(?i)\b(above|below|left|right|top|bottom|beside|next to|under|over)\s+(the|this)`, "location_specific": `(?i)\b(click|tap|press|select)\s+(above|below|left|right|top|bottom)`, "sound_only": `(?i)\b(hear|listen|sound|beep|tone|chime|ring)\b`, "click_color": `(?i)\bclick\s+(the\s+)?(red|green|blue|yellow|orange|purple|pink|black|white|gray|grey)`, "see_shape": `(?i)\bsee\s+(the\s+)?(round|square|circular|rectangular|triangle|diamond|star)`, } // JavaScript code to find all text elements and check for sensory patterns jsCode := `() => { const result = { elements: [] }; // Find all elements with text content const textElements = document.querySelectorAll('p, span, div, label, button, a, li, td, th, h1, h2, h3, h4, h5, h6'); textElements.forEach(element => { const text = element.textContent.trim(); if (text.length > 10 && text.length < 500) { // Reasonable text length result.elements.push({ tagName: element.tagName.toLowerCase(), text: text }); } }); return JSON.stringify(result); }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to find text elements: %v", err) } // Parse the JavaScript result var elementsData struct { Elements []struct { TagName string `json:"tagName"` Text string `json:"text"` } `json:"elements"` } err = json.Unmarshal([]byte(jsResult.Value.Str()), &elementsData) if err != nil { return nil, fmt.Errorf("failed to parse elements data: %v", err) } result := &SensoryCharacteristicsResult{ TotalElements: len(elementsData.Elements), Elements: make([]SensoryCharacteristicsElement, 0), PatternMatches: make(map[string]int), } // Check each element against sensory patterns for _, elem := range elementsData.Elements { matchedPatterns := make([]string, 0) for patternName, patternRegex := range sensoryPatterns { matched, _ := regexp.MatchString(patternRegex, elem.Text) if matched { matchedPatterns = append(matchedPatterns, patternName) result.PatternMatches[patternName]++ } } if len(matchedPatterns) > 0 { element := SensoryCharacteristicsElement{ TagName: elem.TagName, Text: elem.Text, MatchedPatterns: matchedPatterns, } // Determine severity criticalPatterns := []string{"color_only", "shape_only", "sound_only", "click_color", "see_shape"} isCritical := false for _, pattern := range matchedPatterns { for _, critical := range criticalPatterns { if pattern == critical { isCritical = true break } } if isCritical { break } } if isCritical { element.Severity = "violation" element.Recommendation = "Provide additional non-sensory cues (e.g., text labels, ARIA labels, or position in DOM)" result.Violations++ } else { element.Severity = "warning" element.Recommendation = "Review to ensure non-sensory alternatives are available" result.Warnings++ } result.Elements = append(result.Elements, element) result.ElementsWithIssues++ } } d.debugLog("Successfully detected sensory characteristics for tab: %s (elements: %d, issues: %d)", tabID, result.TotalElements, result.ElementsWithIssues) return result, nil } // AnimationFlashResult represents the result of animation/flash detection type AnimationFlashResult struct { TotalAnimations int `json:"total_animations"` FlashingContent int `json:"flashing_content"` RapidAnimations int `json:"rapid_animations"` AutoplayAnimations int `json:"autoplay_animations"` Violations int `json:"violations"` Warnings int `json:"warnings"` Elements []AnimationFlashElement `json:"elements"` } // AnimationFlashElement represents an animated or flashing element type AnimationFlashElement struct { TagName string `json:"tag_name"` Selector string `json:"selector"` AnimationType string `json:"animation_type"` // "css", "gif", "video", "canvas", "svg" FlashRate float64 `json:"flash_rate"` // Flashes per second Duration float64 `json:"duration"` // Animation duration in seconds IsAutoplay bool `json:"is_autoplay"` HasControls bool `json:"has_controls"` CanPause bool `json:"can_pause"` IsViolation bool `json:"is_violation"` ViolationType string `json:"violation_type"` Recommendation string `json:"recommendation"` } // detectAnimationFlash detects animations and flashing content func (d *Daemon) detectAnimationFlash(tabID string, timeout int) (*AnimationFlashResult, error) { d.debugLog("Detecting animation/flash for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // JavaScript code to detect animations and flashing content jsCode := `() => { const result = { elements: [] }; // Helper function to get element selector function getSelector(element) { if (element.id) return '#' + element.id; if (element.className && typeof element.className === 'string') { const classes = element.className.trim().split(/\s+/).slice(0, 2).join('.'); if (classes) return element.tagName.toLowerCase() + '.' + classes; } return element.tagName.toLowerCase(); } // 1. Detect CSS animations document.querySelectorAll('*').forEach(element => { const styles = window.getComputedStyle(element); const animationName = styles.animationName; const animationDuration = parseFloat(styles.animationDuration); const animationIterationCount = styles.animationIterationCount; if (animationName && animationName !== 'none' && animationDuration > 0) { const isInfinite = animationIterationCount === 'infinite'; result.elements.push({ tagName: element.tagName.toLowerCase(), selector: getSelector(element), animationType: 'css', duration: animationDuration, isAutoplay: true, hasControls: false, canPause: false, isInfinite: isInfinite }); } }); // 2. Detect GIF images document.querySelectorAll('img').forEach(img => { if (img.src && (img.src.toLowerCase().endsWith('.gif') || img.src.includes('.gif?'))) { result.elements.push({ tagName: 'img', selector: getSelector(img), animationType: 'gif', duration: 0, // Unknown for GIFs isAutoplay: true, hasControls: false, canPause: false, isInfinite: true }); } }); // 3. Detect video elements document.querySelectorAll('video').forEach(video => { result.elements.push({ tagName: 'video', selector: getSelector(video), animationType: 'video', duration: video.duration || 0, isAutoplay: video.autoplay || false, hasControls: video.controls || false, canPause: true, isInfinite: video.loop || false }); }); // 4. Detect canvas animations (check for requestAnimationFrame usage) document.querySelectorAll('canvas').forEach(canvas => { // We can't directly detect if canvas is animated, but we can flag it for review result.elements.push({ tagName: 'canvas', selector: getSelector(canvas), animationType: 'canvas', duration: 0, isAutoplay: true, // Assume autoplay for canvas hasControls: false, canPause: false, isInfinite: true // Assume infinite for canvas }); }); // 5. Detect SVG animations document.querySelectorAll('svg animate, svg animateTransform, svg animateMotion').forEach(anim => { const svg = anim.closest('svg'); const dur = anim.getAttribute('dur'); const repeatCount = anim.getAttribute('repeatCount'); let duration = 0; if (dur) { duration = parseFloat(dur.replace('s', '')); } result.elements.push({ tagName: 'svg', selector: getSelector(svg), animationType: 'svg', duration: duration, isAutoplay: true, hasControls: false, canPause: false, isInfinite: repeatCount === 'indefinite' }); }); return JSON.stringify(result); }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to detect animations: %v", err) } // Parse the JavaScript result var animationsData struct { Elements []struct { TagName string `json:"tagName"` Selector string `json:"selector"` AnimationType string `json:"animationType"` Duration float64 `json:"duration"` IsAutoplay bool `json:"isAutoplay"` HasControls bool `json:"hasControls"` CanPause bool `json:"canPause"` IsInfinite bool `json:"isInfinite"` } `json:"elements"` } err = json.Unmarshal([]byte(jsResult.Value.Str()), &animationsData) if err != nil { return nil, fmt.Errorf("failed to parse animations data: %v", err) } result := &AnimationFlashResult{ TotalAnimations: len(animationsData.Elements), Elements: make([]AnimationFlashElement, 0), } // Analyze each animation for _, anim := range animationsData.Elements { element := AnimationFlashElement{ TagName: anim.TagName, Selector: anim.Selector, AnimationType: anim.AnimationType, Duration: anim.Duration, IsAutoplay: anim.IsAutoplay, HasControls: anim.HasControls, CanPause: anim.CanPause, } // Estimate flash rate (simplified - real detection would require frame analysis) // For CSS animations with very short durations, assume potential flashing if anim.AnimationType == "css" && anim.Duration > 0 && anim.Duration < 0.5 { element.FlashRate = 1.0 / anim.Duration if element.FlashRate > 3.0 { result.FlashingContent++ } } // Check for violations violations := make([]string, 0) // WCAG 2.3.1: Three Flashes or Below Threshold (Level A) if element.FlashRate > 3.0 { element.IsViolation = true element.ViolationType = "flashing_content" element.Recommendation = "Flashing content exceeds 3 flashes per second. Reduce flash rate or provide mechanism to disable." violations = append(violations, "flashing_content") result.Violations++ } // WCAG 2.2.2: Pause, Stop, Hide (Level A) if anim.IsAutoplay && !anim.HasControls && !anim.CanPause && anim.Duration > 5 { element.IsViolation = true element.ViolationType = "no_pause_control" element.Recommendation = "Animation plays automatically for more than 5 seconds without pause/stop controls." violations = append(violations, "no_pause_control") result.Violations++ } // Warning: Rapid animations (not necessarily flashing) if anim.AnimationType == "css" && anim.Duration > 0 && anim.Duration < 1.0 && anim.IsInfinite { result.RapidAnimations++ if !element.IsViolation { element.ViolationType = "rapid_animation" element.Recommendation = "Rapid infinite animation detected. Consider providing pause controls." result.Warnings++ } } // Count autoplay animations if anim.IsAutoplay { result.AutoplayAnimations++ } result.Elements = append(result.Elements, element) } d.debugLog("Successfully detected animation/flash for tab: %s (total: %d, flashing: %d, violations: %d)", tabID, result.TotalAnimations, result.FlashingContent, result.Violations) return result, nil } // EnhancedAccessibilityResult represents enhanced accessibility tree analysis type EnhancedAccessibilityResult struct { TotalElements int `json:"total_elements"` ElementsWithIssues int `json:"elements_with_issues"` ARIAViolations int `json:"aria_violations"` RoleViolations int `json:"role_violations"` RelationshipIssues int `json:"relationship_issues"` LandmarkIssues int `json:"landmark_issues"` Elements []EnhancedAccessibilityElement `json:"elements"` } // EnhancedAccessibilityElement represents an element with accessibility analysis type EnhancedAccessibilityElement struct { TagName string `json:"tag_name"` Selector string `json:"selector"` Role string `json:"role"` AriaLabel string `json:"aria_label"` AriaDescribedBy string `json:"aria_described_by"` AriaLabelledBy string `json:"aria_labelled_by"` AriaRequired bool `json:"aria_required"` AriaInvalid bool `json:"aria_invalid"` AriaHidden bool `json:"aria_hidden"` TabIndex int `json:"tab_index"` IsInteractive bool `json:"is_interactive"` HasAccessibleName bool `json:"has_accessible_name"` Issues []string `json:"issues"` Recommendations []string `json:"recommendations"` } // analyzeEnhancedAccessibility performs enhanced accessibility tree analysis func (d *Daemon) analyzeEnhancedAccessibility(tabID string, timeout int) (*EnhancedAccessibilityResult, error) { d.debugLog("Analyzing enhanced accessibility for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // JavaScript code for enhanced accessibility analysis jsCode := `() => { const result = { elements: [] }; // Helper function to get element selector function getSelector(element) { if (element.id) return '#' + element.id; if (element.className && typeof element.className === 'string') { const classes = element.className.trim().split(/\s+/).slice(0, 2).join('.'); if (classes) return element.tagName.toLowerCase() + '.' + classes; } return element.tagName.toLowerCase(); } // Helper function to get accessible name function getAccessibleName(element) { // Check aria-label if (element.getAttribute('aria-label')) { return element.getAttribute('aria-label'); } // Check aria-labelledby if (element.getAttribute('aria-labelledby')) { const ids = element.getAttribute('aria-labelledby').split(' '); const labels = ids.map(id => { const el = document.getElementById(id); return el ? el.textContent.trim() : ''; }).filter(t => t); if (labels.length > 0) return labels.join(' '); } // Check label element (for form controls) if (element.id) { const label = document.querySelector('label[for="' + element.id + '"]'); if (label) return label.textContent.trim(); } // Check alt attribute (for images) if (element.hasAttribute('alt')) { return element.getAttribute('alt'); } // Check title attribute if (element.hasAttribute('title')) { return element.getAttribute('title'); } // Check text content (for buttons, links) if (['button', 'a'].includes(element.tagName.toLowerCase())) { return element.textContent.trim(); } return ''; } // Interactive elements that should have accessible names const interactiveSelectors = 'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"]'; document.querySelectorAll(interactiveSelectors).forEach(element => { const role = element.getAttribute('role') || element.tagName.toLowerCase(); const ariaLabel = element.getAttribute('aria-label') || ''; const ariaDescribedBy = element.getAttribute('aria-describedby') || ''; const ariaLabelledBy = element.getAttribute('aria-labelledby') || ''; const ariaRequired = element.getAttribute('aria-required') === 'true'; const ariaInvalid = element.getAttribute('aria-invalid') === 'true'; const ariaHidden = element.getAttribute('aria-hidden') === 'true'; const tabIndex = parseInt(element.getAttribute('tabindex')) || 0; const accessibleName = getAccessibleName(element); const elementData = { tagName: element.tagName.toLowerCase(), selector: getSelector(element), role: role, ariaLabel: ariaLabel, ariaDescribedBy: ariaDescribedBy, ariaLabelledBy: ariaLabelledBy, ariaRequired: ariaRequired, ariaInvalid: ariaInvalid, ariaHidden: ariaHidden, tabIndex: tabIndex, isInteractive: true, hasAccessibleName: accessibleName.length > 0, accessibleName: accessibleName }; result.elements.push(elementData); }); // Check landmarks const landmarks = document.querySelectorAll('main, header, footer, nav, aside, section, [role="main"], [role="banner"], [role="contentinfo"], [role="navigation"], [role="complementary"], [role="region"]'); landmarks.forEach(element => { const role = element.getAttribute('role') || element.tagName.toLowerCase(); const ariaLabel = element.getAttribute('aria-label') || ''; const ariaLabelledBy = element.getAttribute('aria-labelledby') || ''; const accessibleName = getAccessibleName(element); // Multiple landmarks of same type should have labels const sameTypeLandmarks = document.querySelectorAll('[role="' + role + '"], ' + element.tagName.toLowerCase()); const needsLabel = sameTypeLandmarks.length > 1; const elementData = { tagName: element.tagName.toLowerCase(), selector: getSelector(element), role: role, ariaLabel: ariaLabel, ariaDescribedBy: '', ariaLabelledBy: ariaLabelledBy, ariaRequired: false, ariaInvalid: false, ariaHidden: false, tabIndex: 0, isInteractive: false, hasAccessibleName: accessibleName.length > 0, accessibleName: accessibleName, isLandmark: true, needsLabel: needsLabel }; result.elements.push(elementData); }); return JSON.stringify(result); }` jsResult, err := page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to analyze accessibility: %v", err) } // Parse the JavaScript result var elementsData struct { Elements []struct { TagName string `json:"tagName"` Selector string `json:"selector"` Role string `json:"role"` AriaLabel string `json:"ariaLabel"` AriaDescribedBy string `json:"ariaDescribedBy"` AriaLabelledBy string `json:"ariaLabelledBy"` AriaRequired bool `json:"ariaRequired"` AriaInvalid bool `json:"ariaInvalid"` AriaHidden bool `json:"ariaHidden"` TabIndex int `json:"tabIndex"` IsInteractive bool `json:"isInteractive"` HasAccessibleName bool `json:"hasAccessibleName"` AccessibleName string `json:"accessibleName"` IsLandmark bool `json:"isLandmark"` NeedsLabel bool `json:"needsLabel"` } `json:"elements"` } err = json.Unmarshal([]byte(jsResult.Value.Str()), &elementsData) if err != nil { return nil, fmt.Errorf("failed to parse accessibility data: %v", err) } result := &EnhancedAccessibilityResult{ TotalElements: len(elementsData.Elements), Elements: make([]EnhancedAccessibilityElement, 0), } // Analyze each element for _, elem := range elementsData.Elements { element := EnhancedAccessibilityElement{ TagName: elem.TagName, Selector: elem.Selector, Role: elem.Role, AriaLabel: elem.AriaLabel, AriaDescribedBy: elem.AriaDescribedBy, AriaLabelledBy: elem.AriaLabelledBy, AriaRequired: elem.AriaRequired, AriaInvalid: elem.AriaInvalid, AriaHidden: elem.AriaHidden, TabIndex: elem.TabIndex, IsInteractive: elem.IsInteractive, HasAccessibleName: elem.HasAccessibleName, Issues: make([]string, 0), Recommendations: make([]string, 0), } // Check for issues if elem.IsInteractive && !elem.HasAccessibleName { element.Issues = append(element.Issues, "Missing accessible name") element.Recommendations = append(element.Recommendations, "Add aria-label, aria-labelledby, or visible text") result.ARIAViolations++ result.ElementsWithIssues++ } if elem.IsLandmark && elem.NeedsLabel && !elem.HasAccessibleName { element.Issues = append(element.Issues, "Multiple landmarks of same type without distinguishing labels") element.Recommendations = append(element.Recommendations, "Add aria-label to distinguish from other "+elem.Role+" landmarks") result.LandmarkIssues++ result.ElementsWithIssues++ } if elem.AriaHidden && elem.IsInteractive { element.Issues = append(element.Issues, "Interactive element is aria-hidden") element.Recommendations = append(element.Recommendations, "Remove aria-hidden or make element non-interactive") result.ARIAViolations++ result.ElementsWithIssues++ } if elem.TabIndex < -1 { element.Issues = append(element.Issues, "Invalid tabindex value") element.Recommendations = append(element.Recommendations, "Use tabindex=\"0\" for focusable or tabindex=\"-1\" for programmatically focusable") result.ARIAViolations++ result.ElementsWithIssues++ } // Check for aria-describedby/aria-labelledby references if elem.AriaDescribedBy != "" { // Would need to verify IDs exist (simplified here) element.Recommendations = append(element.Recommendations, "Verify aria-describedby references exist") } if elem.AriaLabelledBy != "" { // Would need to verify IDs exist (simplified here) element.Recommendations = append(element.Recommendations, "Verify aria-labelledby references exist") } if len(element.Issues) > 0 { result.Elements = append(result.Elements, element) } } d.debugLog("Successfully analyzed enhanced accessibility for tab: %s (elements: %d, issues: %d)", tabID, result.TotalElements, result.ElementsWithIssues) return result, nil } // KeyboardTestResult represents the result of keyboard navigation testing type KeyboardTestResult struct { TotalInteractive int `json:"total_interactive"` Focusable int `json:"focusable"` NotFocusable int `json:"not_focusable"` NoFocusIndicator int `json:"no_focus_indicator"` KeyboardTraps int `json:"keyboard_traps"` TabOrder []KeyboardTestElement `json:"tab_order"` Issues []KeyboardTestIssue `json:"issues"` } // KeyboardTestElement represents an interactive element in tab order type KeyboardTestElement struct { Index int `json:"index"` Selector string `json:"selector"` TagName string `json:"tag_name"` Role string `json:"role"` Text string `json:"text"` TabIndex int `json:"tab_index"` HasFocusStyle bool `json:"has_focus_style"` IsVisible bool `json:"is_visible"` } // KeyboardTestIssue represents a keyboard accessibility issue type KeyboardTestIssue struct { Type string `json:"type"` Severity string `json:"severity"` Element string `json:"element"` Description string `json:"description"` } // testKeyboardNavigation tests keyboard navigation and accessibility func (d *Daemon) testKeyboardNavigation(tabID string, timeout int) (*KeyboardTestResult, error) { d.debugLog("Testing keyboard navigation for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // JavaScript code to test keyboard navigation jsCode := `() => { const results = { total_interactive: 0, focusable: 0, not_focusable: 0, no_focus_indicator: 0, keyboard_traps: 0, tab_order: [], issues: [] }; // Helper to check if element is visible function isVisible(element) { const style = window.getComputedStyle(element); return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && element.offsetWidth > 0 && element.offsetHeight > 0; } // Helper to get element selector function getSelector(element) { if (element.id) return '#' + element.id; if (element.className && typeof element.className === 'string') { const classes = element.className.trim().split(/\s+/).slice(0, 2).join('.'); if (classes) return element.tagName.toLowerCase() + '.' + classes; } return element.tagName.toLowerCase(); } // Helper to check focus indicator function hasFocusIndicator(element) { element.focus(); const focusedStyle = window.getComputedStyle(element); element.blur(); const blurredStyle = window.getComputedStyle(element); // Check for outline changes if (focusedStyle.outline !== blurredStyle.outline && focusedStyle.outline !== 'none' && focusedStyle.outlineWidth !== '0px') { return true; } // Check for border changes if (focusedStyle.border !== blurredStyle.border) { return true; } // Check for background changes if (focusedStyle.backgroundColor !== blurredStyle.backgroundColor) { return true; } // Check for box-shadow changes if (focusedStyle.boxShadow !== blurredStyle.boxShadow && focusedStyle.boxShadow !== 'none') { return true; } return false; } // Get all interactive elements const interactiveSelectors = [ 'a[href]', 'button', 'input:not([type="hidden"])', 'select', 'textarea', '[tabindex]:not([tabindex="-1"])', '[role="button"]', '[role="link"]', '[role="checkbox"]', '[role="radio"]', '[role="tab"]', '[role="menuitem"]' ]; const allInteractive = document.querySelectorAll(interactiveSelectors.join(',')); results.total_interactive = allInteractive.length; // Test each interactive element allInteractive.forEach((element, index) => { const visible = isVisible(element); const selector = getSelector(element); const tagName = element.tagName.toLowerCase(); const role = element.getAttribute('role') || ''; const text = element.textContent.trim().substring(0, 50); const tabIndex = element.tabIndex; // Check if element is focusable let isFocusable = false; try { element.focus(); isFocusable = document.activeElement === element; element.blur(); } catch (e) { // Element not focusable } if (visible) { if (isFocusable) { results.focusable++; // Check for focus indicator const hasFocus = hasFocusIndicator(element); if (!hasFocus) { results.no_focus_indicator++; results.issues.push({ type: 'no_focus_indicator', severity: 'high', element: selector, description: 'Interactive element lacks visible focus indicator' }); } // Add to tab order results.tab_order.push({ index: results.tab_order.length, selector: selector, tag_name: tagName, role: role, text: text, tab_index: tabIndex, has_focus_style: hasFocus, is_visible: visible }); } else { results.not_focusable++; results.issues.push({ type: 'not_focusable', severity: 'high', element: selector, description: 'Interactive element is not keyboard focusable' }); } } }); // Test for keyboard traps by simulating tab navigation const focusableElements = Array.from(allInteractive).filter(el => { try { el.focus(); const focused = document.activeElement === el; el.blur(); return focused && isVisible(el); } catch (e) { return false; } }); // Simple keyboard trap detection if (focusableElements.length > 0) { const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; // Focus first element firstElement.focus(); // Simulate Shift+Tab from first element // If focus doesn't move to last element or body, might be a trap // Note: This is a simplified check, real trap detection is complex } return JSON.stringify(results); }` var jsResult *proto.RuntimeRemoteObject if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() done := make(chan struct { result *proto.RuntimeRemoteObject err error }, 1) go func() { result, err := page.Eval(jsCode) done <- struct { result *proto.RuntimeRemoteObject err error }{result, err} }() select { case res := <-done: if res.err != nil { return nil, fmt.Errorf("failed to test keyboard navigation: %w", res.err) } jsResult = res.result case <-ctx.Done(): return nil, fmt.Errorf("keyboard navigation test timed out after %d seconds", timeout) } } else { jsResult, err = page.Eval(jsCode) if err != nil { return nil, fmt.Errorf("failed to test keyboard navigation: %w", err) } } // Parse the results resultsJSON := jsResult.Value.Str() var result KeyboardTestResult err = json.Unmarshal([]byte(resultsJSON), &result) if err != nil { return nil, fmt.Errorf("failed to parse keyboard test results: %w", err) } d.debugLog("Successfully tested keyboard navigation for tab: %s (found %d issues)", tabID, len(result.Issues)) return &result, nil } // ZoomTestResult represents the result of zoom level testing type ZoomTestResult struct { ZoomLevels []ZoomLevelTest `json:"zoom_levels"` Issues []ZoomTestIssue `json:"issues"` } // ZoomLevelTest represents testing at a specific zoom level type ZoomLevelTest struct { ZoomLevel float64 `json:"zoom_level"` ViewportWidth int `json:"viewport_width"` ViewportHeight int `json:"viewport_height"` HasHorizontalScroll bool `json:"has_horizontal_scroll"` ContentWidth int `json:"content_width"` ContentHeight int `json:"content_height"` VisibleElements int `json:"visible_elements"` OverflowingElements int `json:"overflowing_elements"` TextReadable bool `json:"text_readable"` } // ZoomTestIssue represents an issue found during zoom testing type ZoomTestIssue struct { ZoomLevel float64 `json:"zoom_level"` Type string `json:"type"` Severity string `json:"severity"` Description string `json:"description"` Element string `json:"element,omitempty"` } // testZoom tests page at different zoom levels func (d *Daemon) testZoom(tabID string, zoomLevels []float64, timeout int) (*ZoomTestResult, error) { d.debugLog("Testing zoom levels for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Default zoom levels if none provided if len(zoomLevels) == 0 { zoomLevels = []float64{1.0, 2.0, 4.0} } result := &ZoomTestResult{ ZoomLevels: make([]ZoomLevelTest, 0, len(zoomLevels)), Issues: make([]ZoomTestIssue, 0), } // Get original viewport size originalViewport, err := page.Eval(`() => { return JSON.stringify({ width: window.innerWidth, height: window.innerHeight }); }`) if err != nil { return nil, fmt.Errorf("failed to get viewport size: %w", err) } var viewportData struct { Width int `json:"width"` Height int `json:"height"` } err = json.Unmarshal([]byte(originalViewport.Value.Str()), &viewportData) if err != nil { return nil, fmt.Errorf("failed to parse viewport data: %w", err) } // Test each zoom level for _, zoom := range zoomLevels { d.debugLog("Testing zoom level: %.1f", zoom) // Set zoom level using Emulation domain err = page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ Width: viewportData.Width, Height: viewportData.Height, DeviceScaleFactor: zoom, Mobile: false, }) if err != nil { d.debugLog("Failed to set zoom level %.1f: %v", zoom, err) result.Issues = append(result.Issues, ZoomTestIssue{ ZoomLevel: zoom, Type: "zoom_error", Severity: "high", Description: fmt.Sprintf("Failed to set zoom level: %v", err), }) continue } // Wait a moment for reflow time.Sleep(500 * time.Millisecond) // JavaScript to analyze page at this zoom level jsCode := `() => { const body = document.body; const html = document.documentElement; // Get content dimensions const contentWidth = Math.max( body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth ); const contentHeight = Math.max( body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight ); // Check for horizontal scroll const hasHorizontalScroll = contentWidth > window.innerWidth; // Count visible elements const allElements = document.querySelectorAll('*'); let visibleCount = 0; let overflowingCount = 0; allElements.forEach(el => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); if (style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0) { visibleCount++; // Check if element overflows viewport if (rect.right > window.innerWidth || rect.left < 0) { overflowingCount++; } } }); // Check text readability (minimum font size) const textElements = document.querySelectorAll('p, span, div, a, button, li, td, th, label'); let minFontSize = Infinity; textElements.forEach(el => { const style = window.getComputedStyle(el); const fontSize = parseFloat(style.fontSize); if (fontSize > 0 && fontSize < minFontSize) { minFontSize = fontSize; } }); // Text is readable if minimum font size is at least 9px (WCAG recommendation) const textReadable = minFontSize >= 9; return JSON.stringify({ viewport_width: window.innerWidth, viewport_height: window.innerHeight, has_horizontal_scroll: hasHorizontalScroll, content_width: contentWidth, content_height: contentHeight, visible_elements: visibleCount, overflowing_elements: overflowingCount, text_readable: textReadable, min_font_size: minFontSize }); }` var jsResult *proto.RuntimeRemoteObject if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() done := make(chan struct { result *proto.RuntimeRemoteObject err error }, 1) go func() { res, err := page.Eval(jsCode) done <- struct { result *proto.RuntimeRemoteObject err error }{res, err} }() select { case res := <-done: if res.err != nil { result.Issues = append(result.Issues, ZoomTestIssue{ ZoomLevel: zoom, Type: "evaluation_error", Severity: "high", Description: fmt.Sprintf("Failed to evaluate page: %v", res.err), }) continue } jsResult = res.result case <-ctx.Done(): result.Issues = append(result.Issues, ZoomTestIssue{ ZoomLevel: zoom, Type: "timeout", Severity: "high", Description: fmt.Sprintf("Evaluation timed out after %d seconds", timeout), }) continue } } else { jsResult, err = page.Eval(jsCode) if err != nil { result.Issues = append(result.Issues, ZoomTestIssue{ ZoomLevel: zoom, Type: "evaluation_error", Severity: "high", Description: fmt.Sprintf("Failed to evaluate page: %v", err), }) continue } } // Parse the results var zoomTest ZoomLevelTest resultStr := jsResult.Value.Str() d.debugLog("Zoom test result string: %s", resultStr) err = json.Unmarshal([]byte(resultStr), &zoomTest) if err != nil { result.Issues = append(result.Issues, ZoomTestIssue{ ZoomLevel: zoom, Type: "parse_error", Severity: "high", Description: fmt.Sprintf("Failed to parse results (got: %s): %v", resultStr, err), }) continue } zoomTest.ZoomLevel = zoom result.ZoomLevels = append(result.ZoomLevels, zoomTest) // Check for issues if zoomTest.HasHorizontalScroll { result.Issues = append(result.Issues, ZoomTestIssue{ ZoomLevel: zoom, Type: "horizontal_scroll", Severity: "medium", Description: "Page has horizontal scrollbar (WCAG 1.4.10 violation)", }) } if zoomTest.OverflowingElements > 0 { result.Issues = append(result.Issues, ZoomTestIssue{ ZoomLevel: zoom, Type: "overflowing_content", Severity: "medium", Description: fmt.Sprintf("%d elements overflow viewport", zoomTest.OverflowingElements), }) } if !zoomTest.TextReadable { result.Issues = append(result.Issues, ZoomTestIssue{ ZoomLevel: zoom, Type: "text_too_small", Severity: "high", Description: "Text size too small for readability", }) } } // Reset viewport to original err = page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ Width: viewportData.Width, Height: viewportData.Height, DeviceScaleFactor: 1.0, Mobile: false, }) if err != nil { d.debugLog("Warning: Failed to reset viewport: %v", err) } d.debugLog("Successfully tested zoom levels for tab: %s (found %d issues)", tabID, len(result.Issues)) return result, nil } // ReflowTestResult represents the result of reflow/responsive testing type ReflowTestResult struct { Breakpoints []ReflowBreakpoint `json:"breakpoints"` Issues []ReflowTestIssue `json:"issues"` } // ReflowBreakpoint represents testing at a specific viewport width type ReflowBreakpoint struct { Width int `json:"width"` Height int `json:"height"` HasHorizontalScroll bool `json:"has_horizontal_scroll"` ContentWidth int `json:"content_width"` ContentHeight int `json:"content_height"` VisibleElements int `json:"visible_elements"` OverflowingElements int `json:"overflowing_elements"` ResponsiveLayout bool `json:"responsive_layout"` } // ReflowTestIssue represents an issue found during reflow testing type ReflowTestIssue struct { Width int `json:"width"` Type string `json:"type"` Severity string `json:"severity"` Description string `json:"description"` Element string `json:"element,omitempty"` } // testReflow tests page at different viewport widths for responsive design func (d *Daemon) testReflow(tabID string, widths []int, timeout int) (*ReflowTestResult, error) { d.debugLog("Testing reflow at different widths for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Default widths if none provided (WCAG 1.4.10 breakpoints) if len(widths) == 0 { widths = []int{320, 1280} } result := &ReflowTestResult{ Breakpoints: make([]ReflowBreakpoint, 0, len(widths)), Issues: make([]ReflowTestIssue, 0), } // Get original viewport size originalViewport, err := page.Eval(`() => { return JSON.stringify({ width: window.innerWidth, height: window.innerHeight }); }`) if err != nil { return nil, fmt.Errorf("failed to get viewport size: %w", err) } var viewportData struct { Width int `json:"width"` Height int `json:"height"` } err = json.Unmarshal([]byte(originalViewport.Value.Str()), &viewportData) if err != nil { return nil, fmt.Errorf("failed to parse viewport data: %w", err) } // Test each width for _, width := range widths { d.debugLog("Testing width: %dpx", width) // Set viewport width err = page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ Width: width, Height: viewportData.Height, DeviceScaleFactor: 1.0, Mobile: width <= 768, // Consider mobile for small widths }) if err != nil { d.debugLog("Failed to set width %d: %v", width, err) result.Issues = append(result.Issues, ReflowTestIssue{ Width: width, Type: "viewport_error", Severity: "high", Description: fmt.Sprintf("Failed to set viewport width: %v", err), }) continue } // Wait for reflow time.Sleep(500 * time.Millisecond) // JavaScript to analyze page at this width jsCode := `() => { const body = document.body; const html = document.documentElement; // Get content dimensions const contentWidth = Math.max( body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth ); const contentHeight = Math.max( body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight ); // Check for horizontal scroll const hasHorizontalScroll = contentWidth > window.innerWidth; // Count visible and overflowing elements const allElements = document.querySelectorAll('*'); let visibleCount = 0; let overflowingCount = 0; allElements.forEach(el => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); if (style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0) { visibleCount++; // Check if element overflows viewport if (rect.right > window.innerWidth + 5 || rect.left < -5) { overflowingCount++; } } }); // Check if layout appears responsive // (content width should not significantly exceed viewport width) const responsiveLayout = contentWidth <= window.innerWidth + 20; return JSON.stringify({ width: window.innerWidth, height: window.innerHeight, has_horizontal_scroll: hasHorizontalScroll, content_width: contentWidth, content_height: contentHeight, visible_elements: visibleCount, overflowing_elements: overflowingCount, responsive_layout: responsiveLayout }); }` var jsResult *proto.RuntimeRemoteObject if timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() done := make(chan struct { result *proto.RuntimeRemoteObject err error }, 1) go func() { res, err := page.Eval(jsCode) done <- struct { result *proto.RuntimeRemoteObject err error }{res, err} }() select { case res := <-done: if res.err != nil { result.Issues = append(result.Issues, ReflowTestIssue{ Width: width, Type: "evaluation_error", Severity: "high", Description: fmt.Sprintf("Failed to evaluate page: %v", res.err), }) continue } jsResult = res.result case <-ctx.Done(): result.Issues = append(result.Issues, ReflowTestIssue{ Width: width, Type: "timeout", Severity: "high", Description: fmt.Sprintf("Evaluation timed out after %d seconds", timeout), }) continue } } else { jsResult, err = page.Eval(jsCode) if err != nil { result.Issues = append(result.Issues, ReflowTestIssue{ Width: width, Type: "evaluation_error", Severity: "high", Description: fmt.Sprintf("Failed to evaluate page: %v", err), }) continue } } // Parse the results var breakpoint ReflowBreakpoint err = json.Unmarshal([]byte(jsResult.Value.Str()), &breakpoint) if err != nil { result.Issues = append(result.Issues, ReflowTestIssue{ Width: width, Type: "parse_error", Severity: "high", Description: fmt.Sprintf("Failed to parse results: %v", err), }) continue } result.Breakpoints = append(result.Breakpoints, breakpoint) // Check for issues if breakpoint.HasHorizontalScroll { result.Issues = append(result.Issues, ReflowTestIssue{ Width: width, Type: "horizontal_scroll", Severity: "high", Description: "Page requires horizontal scrolling (WCAG 1.4.10 violation)", }) } if !breakpoint.ResponsiveLayout { result.Issues = append(result.Issues, ReflowTestIssue{ Width: width, Type: "non_responsive", Severity: "high", Description: fmt.Sprintf("Content width (%dpx) exceeds viewport width (%dpx)", breakpoint.ContentWidth, breakpoint.Width), }) } if breakpoint.OverflowingElements > 0 { result.Issues = append(result.Issues, ReflowTestIssue{ Width: width, Type: "overflowing_content", Severity: "medium", Description: fmt.Sprintf("%d elements overflow viewport", breakpoint.OverflowingElements), }) } } // Reset viewport to original err = page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ Width: viewportData.Width, Height: viewportData.Height, DeviceScaleFactor: 1.0, Mobile: false, }) if err != nil { d.debugLog("Warning: Failed to reset viewport: %v", err) } d.debugLog("Successfully tested reflow for tab: %s (found %d issues)", tabID, len(result.Issues)) return result, nil } // PageAccessibilityReport represents a comprehensive accessibility assessment type PageAccessibilityReport struct { URL string `json:"url"` Timestamp string `json:"timestamp"` ComplianceStatus string `json:"compliance_status"` OverallScore int `json:"overall_score"` LegalRisk string `json:"legal_risk"` CriticalIssues []AccessibilityIssue `json:"critical_issues"` SeriousIssues []AccessibilityIssue `json:"serious_issues"` HighIssues []AccessibilityIssue `json:"high_issues"` MediumIssues []AccessibilityIssue `json:"medium_issues"` SummaryByWCAG map[string]WCAGSummary `json:"summary_by_wcag"` ContrastSummary ContrastSummary `json:"contrast_summary"` KeyboardSummary KeyboardSummary `json:"keyboard_summary"` ARIASummary ARIASummary `json:"aria_summary"` FormSummary *FormSummary `json:"form_summary,omitempty"` Screenshots map[string]string `json:"screenshots,omitempty"` EstimatedHours int `json:"estimated_remediation_hours"` } // AccessibilityIssue represents a single accessibility issue type AccessibilityIssue struct { WCAG string `json:"wcag"` Title string `json:"title"` Description string `json:"description"` Impact string `json:"impact"` Count int `json:"count"` Examples []string `json:"examples,omitempty"` Remediation string `json:"remediation"` } // WCAGSummary represents violations grouped by WCAG principle type WCAGSummary struct { Violations int `json:"violations"` Severity string `json:"severity"` } // ContrastSummary represents a summary of contrast check results type ContrastSummary struct { TotalChecked int `json:"total_checked"` Passed int `json:"passed"` Failed int `json:"failed"` PassRate string `json:"pass_rate"` CriticalFailures []ContrastFailure `json:"critical_failures"` FailurePatterns map[string]FailurePattern `json:"failure_patterns"` } // ContrastFailure represents a critical contrast failure type ContrastFailure struct { Selector string `json:"selector"` Text string `json:"text"` Ratio float64 `json:"ratio"` Required float64 `json:"required"` FgColor string `json:"fg_color"` BgColor string `json:"bg_color"` Fix string `json:"fix"` } // FailurePattern represents a pattern of similar failures type FailurePattern struct { Count int `json:"count"` Ratio float64 `json:"ratio"` Fix string `json:"fix"` } // KeyboardSummary represents a summary of keyboard navigation results type KeyboardSummary struct { TotalInteractive int `json:"total_interactive"` Focusable int `json:"focusable"` MissingFocusIndicator int `json:"missing_focus_indicator"` KeyboardTraps int `json:"keyboard_traps"` TabOrderIssues int `json:"tab_order_issues"` Issues []KeyboardIssue `json:"issues"` } // KeyboardIssue represents a keyboard accessibility issue type KeyboardIssue struct { Type string `json:"type"` Severity string `json:"severity"` Count int `json:"count"` Description string `json:"description"` Fix string `json:"fix"` Examples []string `json:"examples,omitempty"` } // ARIASummary represents a summary of ARIA validation results type ARIASummary struct { TotalViolations int `json:"total_violations"` MissingNames int `json:"missing_names"` InvalidAttributes int `json:"invalid_attributes"` HiddenInteractive int `json:"hidden_interactive"` Issues []ARIAIssue `json:"issues"` } // ARIAIssue represents an ARIA accessibility issue type ARIAIssue struct { Type string `json:"type"` Severity string `json:"severity"` Count int `json:"count"` Description string `json:"description"` Fix string `json:"fix"` Examples []string `json:"examples,omitempty"` } // FormSummary represents a summary of form accessibility type FormSummary struct { FormsFound int `json:"forms_found"` Forms []FormAudit `json:"forms"` } // FormAudit represents accessibility audit of a single form type FormAudit struct { ID string `json:"id"` Fields int `json:"fields"` Issues []FormIssue `json:"issues"` ARIACompliance string `json:"aria_compliance"` KeyboardAccessible bool `json:"keyboard_accessible"` RequiredMarked bool `json:"required_fields_marked"` } // FormIssue represents a form accessibility issue type FormIssue struct { Type string `json:"type"` Severity string `json:"severity"` Count int `json:"count,omitempty"` Description string `json:"description"` Fix string `json:"fix"` Ratio float64 `json:"ratio,omitempty"` } // getPageAccessibilityReport performs a comprehensive accessibility assessment func (d *Daemon) getPageAccessibilityReport(tabID string, tests []string, standard string, includeScreenshots bool, timeout int) (*PageAccessibilityReport, error) { d.debugLog("Getting page accessibility report for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // Get current URL url := page.MustInfo().URL // Initialize report report := &PageAccessibilityReport{ URL: url, Timestamp: time.Now().Format(time.RFC3339), SummaryByWCAG: make(map[string]WCAGSummary), Screenshots: make(map[string]string), } // Run tests based on requested types runAll := len(tests) == 0 || (len(tests) == 1 && tests[0] == "all") // Run axe-core tests if requested if runAll || contains(tests, "wcag") { d.debugLog("Running axe-core WCAG tests...") axeResult, err := d.runAxeCore(tabID, map[string]interface{}{ "runOnly": map[string]interface{}{ "type": "tag", "values": []string{"wcag2a", "wcag2aa", "wcag21aa"}, }, }, timeout) if err == nil { d.processAxeResults(report, axeResult) } } // Run contrast check if requested if runAll || contains(tests, "contrast") { d.debugLog("Running contrast check...") contrastResult, err := d.checkContrast(tabID, "", timeout) if err == nil { d.processContrastResults(report, contrastResult) } } // Run keyboard test if requested if runAll || contains(tests, "keyboard") { d.debugLog("Running keyboard navigation test...") keyboardResult, err := d.testKeyboardNavigation(tabID, timeout) if err == nil { d.processKeyboardResults(report, keyboardResult) } } // Run form analysis if requested if runAll || contains(tests, "forms") { d.debugLog("Running form accessibility audit...") formResult, err := d.getFormAccessibilityAudit(tabID, "", timeout) if err == nil { report.FormSummary = formResult } } // Calculate overall score and compliance status d.calculateOverallScore(report) d.debugLog("Successfully generated page accessibility report for tab: %s", tabID) return report, nil } // Helper function to check if slice contains string func contains(slice []string, str string) bool { for _, s := range slice { if s == str { return true } } return false } // processAxeResults processes axe-core results and adds them to the report func (d *Daemon) processAxeResults(report *PageAccessibilityReport, axeResult *AxeResults) { // Process violations by severity for _, violation := range axeResult.Violations { issue := AccessibilityIssue{ WCAG: extractWCAGCriteria(violation.Tags), Title: violation.Help, Description: violation.Description, Impact: violation.Impact, Count: len(violation.Nodes), Remediation: violation.HelpURL, } // Add examples (limit to 3) for i, node := range violation.Nodes { if i >= 3 { break } if len(node.Target) > 0 { issue.Examples = append(issue.Examples, node.Target[0]) } } // Categorize by impact switch violation.Impact { case "critical": report.CriticalIssues = append(report.CriticalIssues, issue) case "serious": report.SeriousIssues = append(report.SeriousIssues, issue) case "moderate": report.HighIssues = append(report.HighIssues, issue) case "minor": report.MediumIssues = append(report.MediumIssues, issue) } } } // processContrastResults processes contrast check results and adds them to the report func (d *Daemon) processContrastResults(report *PageAccessibilityReport, contrastResult *ContrastCheckResult) { report.ContrastSummary.TotalChecked = contrastResult.TotalElements report.ContrastSummary.Passed = contrastResult.PassedAA report.ContrastSummary.Failed = contrastResult.FailedAA if contrastResult.TotalElements > 0 { passRate := float64(contrastResult.PassedAA) / float64(contrastResult.TotalElements) * 100 report.ContrastSummary.PassRate = fmt.Sprintf("%.1f%%", passRate) } // Extract critical failures (limit to 10) report.ContrastSummary.CriticalFailures = []ContrastFailure{} report.ContrastSummary.FailurePatterns = make(map[string]FailurePattern) count := 0 for _, elem := range contrastResult.Elements { if !elem.PassesAA && count < 10 { failure := ContrastFailure{ Selector: elem.Selector, Text: elem.Text, Ratio: elem.ContrastRatio, Required: elem.RequiredAA, FgColor: elem.ForegroundColor, BgColor: elem.BackgroundColor, Fix: fmt.Sprintf("Increase contrast to at least %.1f:1", elem.RequiredAA), } report.ContrastSummary.CriticalFailures = append(report.ContrastSummary.CriticalFailures, failure) count++ } } } // processKeyboardResults processes keyboard test results and adds them to the report func (d *Daemon) processKeyboardResults(report *PageAccessibilityReport, keyboardResult *KeyboardTestResult) { report.KeyboardSummary.TotalInteractive = keyboardResult.TotalInteractive report.KeyboardSummary.Focusable = keyboardResult.Focusable report.KeyboardSummary.MissingFocusIndicator = keyboardResult.NoFocusIndicator report.KeyboardSummary.KeyboardTraps = keyboardResult.KeyboardTraps // Convert keyboard test issues to summary format if keyboardResult.NoFocusIndicator > 0 { issue := KeyboardIssue{ Type: "missing_focus_indicators", Severity: "HIGH", Count: keyboardResult.NoFocusIndicator, Description: fmt.Sprintf("%d elements lack visible focus indicators", keyboardResult.NoFocusIndicator), Fix: "Add visible :focus styles with outline or border", } report.KeyboardSummary.Issues = append(report.KeyboardSummary.Issues, issue) } if keyboardResult.KeyboardTraps > 0 { issue := KeyboardIssue{ Type: "keyboard_traps", Severity: "CRITICAL", Count: keyboardResult.KeyboardTraps, Description: fmt.Sprintf("%d keyboard traps detected", keyboardResult.KeyboardTraps), Fix: "Ensure users can navigate away from all interactive elements using keyboard", } report.KeyboardSummary.Issues = append(report.KeyboardSummary.Issues, issue) } } // calculateOverallScore calculates the overall accessibility score and compliance status func (d *Daemon) calculateOverallScore(report *PageAccessibilityReport) { // Calculate score based on issues (100 - deductions) score := 100 score -= len(report.CriticalIssues) * 20 // -20 per critical score -= len(report.SeriousIssues) * 10 // -10 per serious score -= len(report.HighIssues) * 5 // -5 per high score -= len(report.MediumIssues) * 2 // -2 per medium if score < 0 { score = 0 } report.OverallScore = score // Determine compliance status if len(report.CriticalIssues) > 0 || len(report.SeriousIssues) > 0 { report.ComplianceStatus = "NON_COMPLIANT" } else if len(report.HighIssues) > 0 { report.ComplianceStatus = "PARTIAL" } else { report.ComplianceStatus = "COMPLIANT" } // Determine legal risk if len(report.CriticalIssues) > 0 { report.LegalRisk = "CRITICAL" } else if len(report.SeriousIssues) > 3 { report.LegalRisk = "HIGH" } else if len(report.SeriousIssues) > 0 || len(report.HighIssues) > 5 { report.LegalRisk = "MEDIUM" } else { report.LegalRisk = "LOW" } // Estimate remediation hours hours := len(report.CriticalIssues)*4 + len(report.SeriousIssues)*2 + len(report.HighIssues)*1 report.EstimatedHours = hours } // extractWCAGCriteria extracts WCAG criteria from tags func extractWCAGCriteria(tags []string) string { for _, tag := range tags { if strings.HasPrefix(tag, "wcag") && strings.Contains(tag, ".") { // Extract number like "wcag144" -> "1.4.4" numStr := strings.TrimPrefix(tag, "wcag") if len(numStr) >= 3 { return fmt.Sprintf("%s.%s.%s", string(numStr[0]), string(numStr[1]), numStr[2:]) } } } return "Unknown" } // ContrastAuditResult represents a smart contrast audit with prioritized failures type ContrastAuditResult struct { TotalChecked int `json:"total_checked"` Passed int `json:"passed"` Failed int `json:"failed"` PassRate string `json:"pass_rate"` CriticalFailures []ContrastFailure `json:"critical_failures"` FailurePatterns map[string]FailurePattern `json:"failure_patterns"` } // getContrastAudit performs a smart contrast check with prioritized failures func (d *Daemon) getContrastAudit(tabID string, prioritySelectors []string, threshold string, timeout int) (*ContrastAuditResult, error) { d.debugLog("Getting contrast audit for tab: %s", tabID) // Run full contrast check contrastResult, err := d.checkContrast(tabID, "", timeout) if err != nil { return nil, fmt.Errorf("failed to check contrast: %v", err) } // Build audit result result := &ContrastAuditResult{ TotalChecked: contrastResult.TotalElements, Passed: contrastResult.PassedAA, Failed: contrastResult.FailedAA, CriticalFailures: []ContrastFailure{}, FailurePatterns: make(map[string]FailurePattern), } if contrastResult.TotalElements > 0 { passRate := float64(contrastResult.PassedAA) / float64(contrastResult.TotalElements) * 100 result.PassRate = fmt.Sprintf("%.1f%%", passRate) } // Extract critical failures (prioritize based on selectors) priorityMap := make(map[string]bool) for _, sel := range prioritySelectors { priorityMap[sel] = true } // First add priority failures, then others (limit to 20 total) count := 0 for _, elem := range contrastResult.Elements { if !elem.PassesAA && count < 20 { failure := ContrastFailure{ Selector: elem.Selector, Text: elem.Text, Ratio: elem.ContrastRatio, Required: elem.RequiredAA, FgColor: elem.ForegroundColor, BgColor: elem.BackgroundColor, Fix: fmt.Sprintf("Increase contrast to at least %.1f:1", elem.RequiredAA), } result.CriticalFailures = append(result.CriticalFailures, failure) count++ } } d.debugLog("Successfully generated contrast audit for tab: %s", tabID) return result, nil } // KeyboardAuditResult represents a keyboard navigation audit type KeyboardAuditResult struct { Status string `json:"status"` TotalInteractive int `json:"total_interactive"` Focusable int `json:"focusable"` Issues []KeyboardIssue `json:"issues"` TabOrderIssues []string `json:"tab_order_issues"` Recommendation string `json:"recommendation"` } // getKeyboardAudit performs a keyboard navigation assessment func (d *Daemon) getKeyboardAudit(tabID string, checkFocusIndicators, checkTabOrder, checkKeyboardTraps bool, timeout int) (*KeyboardAuditResult, error) { d.debugLog("Getting keyboard audit for tab: %s", tabID) // Run keyboard navigation test keyboardResult, err := d.testKeyboardNavigation(tabID, timeout) if err != nil { return nil, fmt.Errorf("failed to test keyboard navigation: %v", err) } // Build audit result result := &KeyboardAuditResult{ TotalInteractive: keyboardResult.TotalInteractive, Focusable: keyboardResult.Focusable, Issues: []KeyboardIssue{}, TabOrderIssues: []string{}, } // Determine status if keyboardResult.KeyboardTraps > 0 { result.Status = "FAIL" } else if keyboardResult.NoFocusIndicator > 0 { result.Status = "PARTIAL" } else { result.Status = "PASS" } // Add issues if checkFocusIndicators && keyboardResult.NoFocusIndicator > 0 { issue := KeyboardIssue{ Type: "missing_focus_indicators", Severity: "HIGH", Count: keyboardResult.NoFocusIndicator, Description: fmt.Sprintf("%d elements lack visible focus indicators", keyboardResult.NoFocusIndicator), Fix: "Add visible :focus styles with outline or border", } result.Issues = append(result.Issues, issue) } if checkKeyboardTraps && keyboardResult.KeyboardTraps > 0 { issue := KeyboardIssue{ Type: "keyboard_traps", Severity: "CRITICAL", Count: keyboardResult.KeyboardTraps, Description: fmt.Sprintf("%d keyboard traps detected", keyboardResult.KeyboardTraps), Fix: "Ensure users can navigate away from all interactive elements using keyboard", } result.Issues = append(result.Issues, issue) } // Generate recommendation if result.Status == "FAIL" { result.Recommendation = "Critical keyboard accessibility issues found. Fix keyboard traps immediately." } else if result.Status == "PARTIAL" { result.Recommendation = "Add visible focus indicators to all interactive elements." } else { result.Recommendation = "Keyboard navigation is accessible." } d.debugLog("Successfully generated keyboard audit for tab: %s", tabID) return result, nil } // getFormAccessibilityAudit performs a comprehensive form accessibility check func (d *Daemon) getFormAccessibilityAudit(tabID, formSelector string, timeout int) (*FormSummary, error) { d.debugLog("Getting form accessibility audit for tab: %s", tabID) page, err := d.getTab(tabID) if err != nil { return nil, fmt.Errorf("failed to get page: %v", err) } // JavaScript to analyze forms jsCode := ` (function() { const forms = document.querySelectorAll('` + formSelector + `' || 'form'); const result = { forms_found: forms.length, forms: [] }; forms.forEach((form, index) => { const formData = { id: form.id || 'form-' + index, fields: form.querySelectorAll('input, select, textarea').length, issues: [], aria_compliance: 'FULL', keyboard_accessible: true, required_fields_marked: true }; // Check for labels const inputs = form.querySelectorAll('input:not([type="hidden"]), select, textarea'); let missingLabels = 0; inputs.forEach(input => { const id = input.id; if (id) { const label = form.querySelector('label[for="' + id + '"]'); if (!label && !input.getAttribute('aria-label') && !input.getAttribute('aria-labelledby')) { missingLabels++; } } }); if (missingLabels > 0) { formData.issues.push({ type: 'missing_labels', severity: 'SERIOUS', count: missingLabels, description: missingLabels + ' fields lack proper labels', fix: 'Add