This commit is contained in:
Josh at WLTechBlog
2025-10-03 12:49:50 -05:00
parent 3dc3a4caf1
commit a3a70d5091
11 changed files with 2634 additions and 2 deletions

BIN
daemon/cremotedaemon Executable file → Normal file

Binary file not shown.

View File

@@ -2159,6 +2159,103 @@ func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) {
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"}
@@ -11657,3 +11754,629 @@ func (d *Daemon) testReflow(tabID string, widths []int, timeout int) (*ReflowTes
d.debugLog("Successfully tested reflow for tab: %s (found %d issues)", tabID, len(result.Issues))
return result, nil
}
// PageAccessibilityReport represents a comprehensive accessibility assessment
type PageAccessibilityReport struct {
URL string `json:"url"`
Timestamp string `json:"timestamp"`
ComplianceStatus string `json:"compliance_status"`
OverallScore int `json:"overall_score"`
LegalRisk string `json:"legal_risk"`
CriticalIssues []AccessibilityIssue `json:"critical_issues"`
SeriousIssues []AccessibilityIssue `json:"serious_issues"`
HighIssues []AccessibilityIssue `json:"high_issues"`
MediumIssues []AccessibilityIssue `json:"medium_issues"`
SummaryByWCAG map[string]WCAGSummary `json:"summary_by_wcag"`
ContrastSummary ContrastSummary `json:"contrast_summary"`
KeyboardSummary KeyboardSummary `json:"keyboard_summary"`
ARIASummary ARIASummary `json:"aria_summary"`
FormSummary *FormSummary `json:"form_summary,omitempty"`
Screenshots map[string]string `json:"screenshots,omitempty"`
EstimatedHours int `json:"estimated_remediation_hours"`
}
// AccessibilityIssue represents a single accessibility issue
type AccessibilityIssue struct {
WCAG string `json:"wcag"`
Title string `json:"title"`
Description string `json:"description"`
Impact string `json:"impact"`
Count int `json:"count"`
Examples []string `json:"examples,omitempty"`
Remediation string `json:"remediation"`
}
// WCAGSummary represents violations grouped by WCAG principle
type WCAGSummary struct {
Violations int `json:"violations"`
Severity string `json:"severity"`
}
// ContrastSummary represents a summary of contrast check results
type ContrastSummary struct {
TotalChecked int `json:"total_checked"`
Passed int `json:"passed"`
Failed int `json:"failed"`
PassRate string `json:"pass_rate"`
CriticalFailures []ContrastFailure `json:"critical_failures"`
FailurePatterns map[string]FailurePattern `json:"failure_patterns"`
}
// ContrastFailure represents a critical contrast failure
type ContrastFailure struct {
Selector string `json:"selector"`
Text string `json:"text"`
Ratio float64 `json:"ratio"`
Required float64 `json:"required"`
FgColor string `json:"fg_color"`
BgColor string `json:"bg_color"`
Fix string `json:"fix"`
}
// FailurePattern represents a pattern of similar failures
type FailurePattern struct {
Count int `json:"count"`
Ratio float64 `json:"ratio"`
Fix string `json:"fix"`
}
// KeyboardSummary represents a summary of keyboard navigation results
type KeyboardSummary struct {
TotalInteractive int `json:"total_interactive"`
Focusable int `json:"focusable"`
MissingFocusIndicator int `json:"missing_focus_indicator"`
KeyboardTraps int `json:"keyboard_traps"`
TabOrderIssues int `json:"tab_order_issues"`
Issues []KeyboardIssue `json:"issues"`
}
// KeyboardIssue represents a keyboard accessibility issue
type KeyboardIssue struct {
Type string `json:"type"`
Severity string `json:"severity"`
Count int `json:"count"`
Description string `json:"description"`
Fix string `json:"fix"`
Examples []string `json:"examples,omitempty"`
}
// ARIASummary represents a summary of ARIA validation results
type ARIASummary struct {
TotalViolations int `json:"total_violations"`
MissingNames int `json:"missing_names"`
InvalidAttributes int `json:"invalid_attributes"`
HiddenInteractive int `json:"hidden_interactive"`
Issues []ARIAIssue `json:"issues"`
}
// ARIAIssue represents an ARIA accessibility issue
type ARIAIssue struct {
Type string `json:"type"`
Severity string `json:"severity"`
Count int `json:"count"`
Description string `json:"description"`
Fix string `json:"fix"`
Examples []string `json:"examples,omitempty"`
}
// FormSummary represents a summary of form accessibility
type FormSummary struct {
FormsFound int `json:"forms_found"`
Forms []FormAudit `json:"forms"`
}
// FormAudit represents accessibility audit of a single form
type FormAudit struct {
ID string `json:"id"`
Fields int `json:"fields"`
Issues []FormIssue `json:"issues"`
ARIACompliance string `json:"aria_compliance"`
KeyboardAccessible bool `json:"keyboard_accessible"`
RequiredMarked bool `json:"required_fields_marked"`
}
// FormIssue represents a form accessibility issue
type FormIssue struct {
Type string `json:"type"`
Severity string `json:"severity"`
Count int `json:"count,omitempty"`
Description string `json:"description"`
Fix string `json:"fix"`
Ratio float64 `json:"ratio,omitempty"`
}
// getPageAccessibilityReport performs a comprehensive accessibility assessment
func (d *Daemon) getPageAccessibilityReport(tabID string, tests []string, standard string, includeScreenshots bool, timeout int) (*PageAccessibilityReport, error) {
d.debugLog("Getting page accessibility report for tab: %s", tabID)
page, err := d.getTab(tabID)
if err != nil {
return nil, fmt.Errorf("failed to get page: %v", err)
}
// Get current URL
url := page.MustInfo().URL
// Initialize report
report := &PageAccessibilityReport{
URL: url,
Timestamp: time.Now().Format(time.RFC3339),
SummaryByWCAG: make(map[string]WCAGSummary),
Screenshots: make(map[string]string),
}
// Run tests based on requested types
runAll := len(tests) == 0 || (len(tests) == 1 && tests[0] == "all")
// Run axe-core tests if requested
if runAll || contains(tests, "wcag") {
d.debugLog("Running axe-core WCAG tests...")
axeResult, err := d.runAxeCore(tabID, map[string]interface{}{
"runOnly": map[string]interface{}{
"type": "tag",
"values": []string{"wcag2a", "wcag2aa", "wcag21aa"},
},
}, timeout)
if err == nil {
d.processAxeResults(report, axeResult)
}
}
// Run contrast check if requested
if runAll || contains(tests, "contrast") {
d.debugLog("Running contrast check...")
contrastResult, err := d.checkContrast(tabID, "", timeout)
if err == nil {
d.processContrastResults(report, contrastResult)
}
}
// Run keyboard test if requested
if runAll || contains(tests, "keyboard") {
d.debugLog("Running keyboard navigation test...")
keyboardResult, err := d.testKeyboardNavigation(tabID, timeout)
if err == nil {
d.processKeyboardResults(report, keyboardResult)
}
}
// Run form analysis if requested
if runAll || contains(tests, "forms") {
d.debugLog("Running form accessibility audit...")
formResult, err := d.getFormAccessibilityAudit(tabID, "", timeout)
if err == nil {
report.FormSummary = formResult
}
}
// Calculate overall score and compliance status
d.calculateOverallScore(report)
d.debugLog("Successfully generated page accessibility report for tab: %s", tabID)
return report, nil
}
// Helper function to check if slice contains string
func contains(slice []string, str string) bool {
for _, s := range slice {
if s == str {
return true
}
}
return false
}
// processAxeResults processes axe-core results and adds them to the report
func (d *Daemon) processAxeResults(report *PageAccessibilityReport, axeResult *AxeResults) {
// Process violations by severity
for _, violation := range axeResult.Violations {
issue := AccessibilityIssue{
WCAG: extractWCAGCriteria(violation.Tags),
Title: violation.Help,
Description: violation.Description,
Impact: violation.Impact,
Count: len(violation.Nodes),
Remediation: violation.HelpURL,
}
// Add examples (limit to 3)
for i, node := range violation.Nodes {
if i >= 3 {
break
}
if len(node.Target) > 0 {
issue.Examples = append(issue.Examples, node.Target[0])
}
}
// Categorize by impact
switch violation.Impact {
case "critical":
report.CriticalIssues = append(report.CriticalIssues, issue)
case "serious":
report.SeriousIssues = append(report.SeriousIssues, issue)
case "moderate":
report.HighIssues = append(report.HighIssues, issue)
case "minor":
report.MediumIssues = append(report.MediumIssues, issue)
}
}
}
// processContrastResults processes contrast check results and adds them to the report
func (d *Daemon) processContrastResults(report *PageAccessibilityReport, contrastResult *ContrastCheckResult) {
report.ContrastSummary.TotalChecked = contrastResult.TotalElements
report.ContrastSummary.Passed = contrastResult.PassedAA
report.ContrastSummary.Failed = contrastResult.FailedAA
if contrastResult.TotalElements > 0 {
passRate := float64(contrastResult.PassedAA) / float64(contrastResult.TotalElements) * 100
report.ContrastSummary.PassRate = fmt.Sprintf("%.1f%%", passRate)
}
// Extract critical failures (limit to 10)
report.ContrastSummary.CriticalFailures = []ContrastFailure{}
report.ContrastSummary.FailurePatterns = make(map[string]FailurePattern)
count := 0
for _, elem := range contrastResult.Elements {
if !elem.PassesAA && count < 10 {
failure := ContrastFailure{
Selector: elem.Selector,
Text: elem.Text,
Ratio: elem.ContrastRatio,
Required: elem.RequiredAA,
FgColor: elem.ForegroundColor,
BgColor: elem.BackgroundColor,
Fix: fmt.Sprintf("Increase contrast to at least %.1f:1", elem.RequiredAA),
}
report.ContrastSummary.CriticalFailures = append(report.ContrastSummary.CriticalFailures, failure)
count++
}
}
}
// processKeyboardResults processes keyboard test results and adds them to the report
func (d *Daemon) processKeyboardResults(report *PageAccessibilityReport, keyboardResult *KeyboardTestResult) {
report.KeyboardSummary.TotalInteractive = keyboardResult.TotalInteractive
report.KeyboardSummary.Focusable = keyboardResult.Focusable
report.KeyboardSummary.MissingFocusIndicator = keyboardResult.NoFocusIndicator
report.KeyboardSummary.KeyboardTraps = keyboardResult.KeyboardTraps
// Convert keyboard test issues to summary format
if keyboardResult.NoFocusIndicator > 0 {
issue := KeyboardIssue{
Type: "missing_focus_indicators",
Severity: "HIGH",
Count: keyboardResult.NoFocusIndicator,
Description: fmt.Sprintf("%d elements lack visible focus indicators", keyboardResult.NoFocusIndicator),
Fix: "Add visible :focus styles with outline or border",
}
report.KeyboardSummary.Issues = append(report.KeyboardSummary.Issues, issue)
}
if keyboardResult.KeyboardTraps > 0 {
issue := KeyboardIssue{
Type: "keyboard_traps",
Severity: "CRITICAL",
Count: keyboardResult.KeyboardTraps,
Description: fmt.Sprintf("%d keyboard traps detected", keyboardResult.KeyboardTraps),
Fix: "Ensure users can navigate away from all interactive elements using keyboard",
}
report.KeyboardSummary.Issues = append(report.KeyboardSummary.Issues, issue)
}
}
// calculateOverallScore calculates the overall accessibility score and compliance status
func (d *Daemon) calculateOverallScore(report *PageAccessibilityReport) {
// Calculate score based on issues (100 - deductions)
score := 100
score -= len(report.CriticalIssues) * 20 // -20 per critical
score -= len(report.SeriousIssues) * 10 // -10 per serious
score -= len(report.HighIssues) * 5 // -5 per high
score -= len(report.MediumIssues) * 2 // -2 per medium
if score < 0 {
score = 0
}
report.OverallScore = score
// Determine compliance status
if len(report.CriticalIssues) > 0 || len(report.SeriousIssues) > 0 {
report.ComplianceStatus = "NON_COMPLIANT"
} else if len(report.HighIssues) > 0 {
report.ComplianceStatus = "PARTIAL"
} else {
report.ComplianceStatus = "COMPLIANT"
}
// Determine legal risk
if len(report.CriticalIssues) > 0 {
report.LegalRisk = "CRITICAL"
} else if len(report.SeriousIssues) > 3 {
report.LegalRisk = "HIGH"
} else if len(report.SeriousIssues) > 0 || len(report.HighIssues) > 5 {
report.LegalRisk = "MEDIUM"
} else {
report.LegalRisk = "LOW"
}
// Estimate remediation hours
hours := len(report.CriticalIssues)*4 + len(report.SeriousIssues)*2 + len(report.HighIssues)*1
report.EstimatedHours = hours
}
// extractWCAGCriteria extracts WCAG criteria from tags
func extractWCAGCriteria(tags []string) string {
for _, tag := range tags {
if strings.HasPrefix(tag, "wcag") && strings.Contains(tag, ".") {
// Extract number like "wcag144" -> "1.4.4"
numStr := strings.TrimPrefix(tag, "wcag")
if len(numStr) >= 3 {
return fmt.Sprintf("%s.%s.%s", string(numStr[0]), string(numStr[1]), numStr[2:])
}
}
}
return "Unknown"
}
// ContrastAuditResult represents a smart contrast audit with prioritized failures
type ContrastAuditResult struct {
TotalChecked int `json:"total_checked"`
Passed int `json:"passed"`
Failed int `json:"failed"`
PassRate string `json:"pass_rate"`
CriticalFailures []ContrastFailure `json:"critical_failures"`
FailurePatterns map[string]FailurePattern `json:"failure_patterns"`
}
// getContrastAudit performs a smart contrast check with prioritized failures
func (d *Daemon) getContrastAudit(tabID string, prioritySelectors []string, threshold string, timeout int) (*ContrastAuditResult, error) {
d.debugLog("Getting contrast audit for tab: %s", tabID)
// Run full contrast check
contrastResult, err := d.checkContrast(tabID, "", timeout)
if err != nil {
return nil, fmt.Errorf("failed to check contrast: %v", err)
}
// Build audit result
result := &ContrastAuditResult{
TotalChecked: contrastResult.TotalElements,
Passed: contrastResult.PassedAA,
Failed: contrastResult.FailedAA,
CriticalFailures: []ContrastFailure{},
FailurePatterns: make(map[string]FailurePattern),
}
if contrastResult.TotalElements > 0 {
passRate := float64(contrastResult.PassedAA) / float64(contrastResult.TotalElements) * 100
result.PassRate = fmt.Sprintf("%.1f%%", passRate)
}
// Extract critical failures (prioritize based on selectors)
priorityMap := make(map[string]bool)
for _, sel := range prioritySelectors {
priorityMap[sel] = true
}
// First add priority failures, then others (limit to 20 total)
count := 0
for _, elem := range contrastResult.Elements {
if !elem.PassesAA && count < 20 {
failure := ContrastFailure{
Selector: elem.Selector,
Text: elem.Text,
Ratio: elem.ContrastRatio,
Required: elem.RequiredAA,
FgColor: elem.ForegroundColor,
BgColor: elem.BackgroundColor,
Fix: fmt.Sprintf("Increase contrast to at least %.1f:1", elem.RequiredAA),
}
result.CriticalFailures = append(result.CriticalFailures, failure)
count++
}
}
d.debugLog("Successfully generated contrast audit for tab: %s", tabID)
return result, nil
}
// KeyboardAuditResult represents a keyboard navigation audit
type KeyboardAuditResult struct {
Status string `json:"status"`
TotalInteractive int `json:"total_interactive"`
Focusable int `json:"focusable"`
Issues []KeyboardIssue `json:"issues"`
TabOrderIssues []string `json:"tab_order_issues"`
Recommendation string `json:"recommendation"`
}
// getKeyboardAudit performs a keyboard navigation assessment
func (d *Daemon) getKeyboardAudit(tabID string, checkFocusIndicators, checkTabOrder, checkKeyboardTraps bool, timeout int) (*KeyboardAuditResult, error) {
d.debugLog("Getting keyboard audit for tab: %s", tabID)
// Run keyboard navigation test
keyboardResult, err := d.testKeyboardNavigation(tabID, timeout)
if err != nil {
return nil, fmt.Errorf("failed to test keyboard navigation: %v", err)
}
// Build audit result
result := &KeyboardAuditResult{
TotalInteractive: keyboardResult.TotalInteractive,
Focusable: keyboardResult.Focusable,
Issues: []KeyboardIssue{},
TabOrderIssues: []string{},
}
// Determine status
if keyboardResult.KeyboardTraps > 0 {
result.Status = "FAIL"
} else if keyboardResult.NoFocusIndicator > 0 {
result.Status = "PARTIAL"
} else {
result.Status = "PASS"
}
// Add issues
if checkFocusIndicators && keyboardResult.NoFocusIndicator > 0 {
issue := KeyboardIssue{
Type: "missing_focus_indicators",
Severity: "HIGH",
Count: keyboardResult.NoFocusIndicator,
Description: fmt.Sprintf("%d elements lack visible focus indicators", keyboardResult.NoFocusIndicator),
Fix: "Add visible :focus styles with outline or border",
}
result.Issues = append(result.Issues, issue)
}
if checkKeyboardTraps && keyboardResult.KeyboardTraps > 0 {
issue := KeyboardIssue{
Type: "keyboard_traps",
Severity: "CRITICAL",
Count: keyboardResult.KeyboardTraps,
Description: fmt.Sprintf("%d keyboard traps detected", keyboardResult.KeyboardTraps),
Fix: "Ensure users can navigate away from all interactive elements using keyboard",
}
result.Issues = append(result.Issues, issue)
}
// Generate recommendation
if result.Status == "FAIL" {
result.Recommendation = "Critical keyboard accessibility issues found. Fix keyboard traps immediately."
} else if result.Status == "PARTIAL" {
result.Recommendation = "Add visible focus indicators to all interactive elements."
} else {
result.Recommendation = "Keyboard navigation is accessible."
}
d.debugLog("Successfully generated keyboard audit for tab: %s", tabID)
return result, nil
}
// getFormAccessibilityAudit performs a comprehensive form accessibility check
func (d *Daemon) getFormAccessibilityAudit(tabID, formSelector string, timeout int) (*FormSummary, error) {
d.debugLog("Getting form accessibility audit for tab: %s", tabID)
page, err := d.getTab(tabID)
if err != nil {
return nil, fmt.Errorf("failed to get page: %v", err)
}
// JavaScript to analyze forms
jsCode := `
(function() {
const forms = document.querySelectorAll('` + formSelector + `' || 'form');
const result = {
forms_found: forms.length,
forms: []
};
forms.forEach((form, index) => {
const formData = {
id: form.id || 'form-' + index,
fields: form.querySelectorAll('input, select, textarea').length,
issues: [],
aria_compliance: 'FULL',
keyboard_accessible: true,
required_fields_marked: true
};
// Check for labels
const inputs = form.querySelectorAll('input:not([type="hidden"]), select, textarea');
let missingLabels = 0;
inputs.forEach(input => {
const id = input.id;
if (id) {
const label = form.querySelector('label[for="' + id + '"]');
if (!label && !input.getAttribute('aria-label') && !input.getAttribute('aria-labelledby')) {
missingLabels++;
}
}
});
if (missingLabels > 0) {
formData.issues.push({
type: 'missing_labels',
severity: 'SERIOUS',
count: missingLabels,
description: missingLabels + ' fields lack proper labels',
fix: 'Add <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;
})();
`
// 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)
}
if err := json.Unmarshal([]byte(result.result.Value.String()), &resultData); err != nil {
return nil, fmt.Errorf("failed to parse form analysis result: %v", err)
}
}
} else {
res, err := page.Eval(jsCode)
if err != nil {
return nil, fmt.Errorf("failed to analyze forms: %v", err)
}
if err := json.Unmarshal([]byte(res.Value.String()), &resultData); err != nil {
return nil, fmt.Errorf("failed to parse form analysis result: %v", err)
}
}
// 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
}