Files
cremote/daemon/daemon.go
Josh at WLTechBlog ccd8c77a3e remove sensory tools
2025-10-07 11:47:47 -05:00

12246 lines
358 KiB
Go

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 "<unavailable>"
}
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-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<boolean>} - 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<boolean>} - 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<object>} - 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<object>} - 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
}
// 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)
}
// Determine the selector to use
selector := formSelector
if selector == "" {
selector = "form"
}
// JavaScript to analyze forms
// Note: page.Eval() expects an arrow function, not an IIFE
jsCode := `() => {
try {
const forms = document.querySelectorAll('` + selector + `');
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 <label> elements or aria-label attributes'
});
formData.aria_compliance = 'PARTIAL';
}
// Check submit button contrast (simplified)
const submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
if (submitBtn) {
const styles = window.getComputedStyle(submitBtn);
// Note: Actual contrast calculation would be more complex
formData.issues.push({
type: 'submit_button_check',
severity: 'INFO',
description: 'Submit button found - verify contrast manually',
fix: 'Ensure submit button has 4.5:1 contrast ratio'
});
}
result.forms.push(formData);
});
return result;
} catch (error) {
return {
error: error.message,
forms_found: 0,
forms: []
};
}
}`
// Execute JavaScript with timeout
var resultData interface{}
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 <-ctx.Done():
return nil, fmt.Errorf("form analysis timed out after %d seconds", timeout)
case result := <-done:
if result.err != nil {
return nil, fmt.Errorf("failed to analyze forms: %v", result.err)
}
// Convert the result to JSON
jsonBytes, err := json.Marshal(result.result.Value.Val())
if err != nil {
return nil, fmt.Errorf("failed to marshal result value: %v (value type: %T, value: %v)", err, result.result.Value.Val(), result.result.Value.Val())
}
if err := json.Unmarshal(jsonBytes, &resultData); err != nil {
return nil, fmt.Errorf("failed to parse form analysis result: %v (json: %s)", err, string(jsonBytes))
}
}
} else {
res, err := page.Eval(jsCode)
if err != nil {
return nil, fmt.Errorf("failed to analyze forms: %v", err)
}
// Convert the result to JSON
jsonBytes, err := json.Marshal(res.Value.Val())
if err != nil {
return nil, fmt.Errorf("failed to marshal result value: %v (value type: %T, value: %v)", err, res.Value.Val(), res.Value.Val())
}
if err := json.Unmarshal(jsonBytes, &resultData); err != nil {
return nil, fmt.Errorf("failed to parse form analysis result: %v (json: %s)", err, string(jsonBytes))
}
}
// Convert to FormSummary
var summary FormSummary
dataBytes, err := json.Marshal(resultData)
if err != nil {
return nil, fmt.Errorf("failed to marshal form data: %v", err)
}
if err := json.Unmarshal(dataBytes, &summary); err != nil {
return nil, fmt.Errorf("failed to unmarshal form summary: %v", err)
}
d.debugLog("Successfully generated form accessibility audit for tab: %s (found %d forms)", tabID, summary.FormsFound)
return &summary, nil
}