12246 lines
358 KiB
Go
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
|
|
}
|