From fb7e07aae94c3413285697a30d602963cb7a586a Mon Sep 17 00:00:00 2001 From: Josh at WLTechBlog Date: Tue, 9 Dec 2025 07:59:22 -0700 Subject: [PATCH] Always use TAB key for keyboard navigation instead of javascript focus() --- client/client.go | 12 +- daemon/daemon.go | 256 +------------------------------ docs/REAL_KEYBOARD_SIMULATION.md | 69 +++++---- mcp/main.go | 20 +-- 4 files changed, 53 insertions(+), 304 deletions(-) diff --git a/client/client.go b/client/client.go index a5afbf9..c885df2 100644 --- a/client/client.go +++ b/client/client.go @@ -4146,10 +4146,8 @@ func (c *Client) TestKeyboardNavigation(tabID string, useRealKeys bool, timeout params["tab"] = tabID } - // Add use_real_keys parameter - if useRealKeys { - params["use_real_keys"] = "true" - } + // Note: useRealKeys parameter is ignored - always uses real keyboard simulation + // for accurate :focus-visible detection // Add timeout if specified if timeout > 0 { @@ -4501,10 +4499,8 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr params["check_keyboard_traps"] = "true" } - // Add use_real_keys parameter (default to true for better accuracy) - if !useRealKeys { - params["use_real_keys"] = "false" - } + // Note: useRealKeys parameter is ignored - always uses real keyboard simulation + // for accurate :focus-visible detection // Add timeout if specified if timeout > 0 { diff --git a/daemon/daemon.go b/daemon/daemon.go index 88663fd..9ed0017 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -2062,7 +2062,6 @@ func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) { case "test-keyboard": tabID := cmd.Params["tab"] timeoutStr := cmd.Params["timeout"] - useRealKeys := cmd.Params["use_real_keys"] == "true" // Default to false for backward compatibility // Parse timeout (default to 15 seconds for comprehensive testing) timeout := 15 @@ -2072,15 +2071,8 @@ func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) { } } - var result *KeyboardTestResult - var err error - - // Use real keyboard simulation if requested, otherwise use legacy method - if useRealKeys { - result, err = d.testKeyboardNavigationWithRealKeys(tabID, timeout) - } else { - result, err = d.testKeyboardNavigation(tabID, timeout) - } + // Always use real keyboard simulation for accurate :focus-visible detection + result, err := d.testKeyboardNavigationWithRealKeys(tabID, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} @@ -2210,7 +2202,6 @@ func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) { checkFocusIndicators := cmd.Params["check_focus_indicators"] == "true" checkTabOrder := cmd.Params["check_tab_order"] == "true" checkKeyboardTraps := cmd.Params["check_keyboard_traps"] == "true" - useRealKeys := cmd.Params["use_real_keys"] != "false" // Default to true for better accuracy timeoutStr := cmd.Params["timeout"] // Parse timeout (default to 15 seconds) @@ -2221,7 +2212,8 @@ func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) { } } - result, err := d.getKeyboardAudit(tabID, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, useRealKeys, timeout) + // Always use real keyboard simulation for accurate :focus-visible detection + result, err := d.getKeyboardAudit(tabID, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, true, timeout) if err != nil { response = Response{Success: false, Error: err.Error()} } else { @@ -11484,232 +11476,6 @@ func (d *Daemon) testKeyboardNavigationWithRealKeys(tabID string, timeout int) ( return &initialResult, nil } -// testKeyboardNavigation tests keyboard navigation and accessibility -// This is the legacy version using programmatic .focus() -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"` @@ -12678,18 +12444,10 @@ type KeyboardAuditResult struct { // getKeyboardAudit performs a keyboard navigation assessment func (d *Daemon) getKeyboardAudit(tabID string, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, useRealKeys bool, timeout int) (*KeyboardAuditResult, error) { - d.debugLog("Getting keyboard audit for tab: %s (useRealKeys: %v)", tabID, useRealKeys) - - // Run keyboard navigation test with real keys or legacy method - var keyboardResult *KeyboardTestResult - var err error - - if useRealKeys { - keyboardResult, err = d.testKeyboardNavigationWithRealKeys(tabID, timeout) - } else { - keyboardResult, err = d.testKeyboardNavigation(tabID, timeout) - } + d.debugLog("Getting keyboard audit for tab: %s", tabID) + // Always use real keyboard simulation for accurate :focus-visible detection + keyboardResult, err := d.testKeyboardNavigationWithRealKeys(tabID, timeout) if err != nil { return nil, fmt.Errorf("failed to test keyboard navigation: %v", err) } diff --git a/docs/REAL_KEYBOARD_SIMULATION.md b/docs/REAL_KEYBOARD_SIMULATION.md index 6cb41cc..42be076 100644 --- a/docs/REAL_KEYBOARD_SIMULATION.md +++ b/docs/REAL_KEYBOARD_SIMULATION.md @@ -1,21 +1,25 @@ # Real Keyboard Simulation for Focus Indicator Testing -**Date:** 2025-11-20 -**Status:** ✅ **IMPLEMENTED** +**Date:** 2025-11-20 +**Updated:** 2025-12-09 +**Status:** ✅ **IMPLEMENTED** (Now the only method - legacy programmatic method removed) --- ## Overview -We've implemented real Tab key simulation for keyboard navigation testing to solve the `:focus-within` detection issue identified in `docs/FOCUS_INDICATORS_VALIDATION_SUCCESS.md`. +Cremote **always uses real Tab key simulation** for keyboard navigation testing. The legacy programmatic `.focus()` method has been removed because it produces false negatives. ### The Problem -**Programmatic `.focus()` cannot trigger `:focus-within` on parent elements**, causing false negatives for dropdown menus and other elements that rely on CSS `:focus-within` pseudo-class to become visible. +**Programmatic `.focus()` cannot trigger `:focus-within` or `:focus-visible` on elements**, causing false negatives for: +- Dropdown menus that rely on CSS `:focus-within` pseudo-class +- Modern focus indicators using `:focus-visible` pseudo-class +- Accessibility plugins that inject universal focus styles ### The Solution -**Use real Tab key presses via Chrome DevTools Protocol** to simulate actual user keyboard navigation, which properly triggers all CSS pseudo-classes including `:focus-within`. +**Always use real Tab key presses via Chrome DevTools Protocol** to simulate actual user keyboard navigation, which properly triggers all CSS pseudo-classes including `:focus-within` and `:focus-visible`. --- @@ -47,35 +51,29 @@ Located in `daemon/daemon.go` (lines 10849-11148), this function: ### Client Function: `TestKeyboardNavigation()` -**Old signature:** -```go -func (c *Client) TestKeyboardNavigation(tabID string, timeout int) (*KeyboardTestResult, error) -``` - -**New signature:** +**Current signature:** ```go func (c *Client) TestKeyboardNavigation(tabID string, useRealKeys bool, timeout int) (*KeyboardTestResult, error) ``` **Parameters:** - `tabID` - Tab ID (empty string uses current tab) -- `useRealKeys` - `true` for real Tab simulation (recommended), `false` for legacy programmatic focus +- `useRealKeys` - **Ignored** (always uses real Tab simulation for accuracy) - `timeout` - Timeout in seconds +**Note:** The `useRealKeys` parameter is maintained for backward compatibility but is ignored. All keyboard testing now uses real Tab key simulation. + ### Client Function: `GetKeyboardAudit()` -**Old signature:** -```go -func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOrder, checkKeyboardTraps bool, timeout int) (*KeyboardAuditResult, error) -``` - -**New signature:** +**Current signature:** ```go func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, useRealKeys bool, timeout int) (*KeyboardAuditResult, error) ``` **Parameters:** -- `useRealKeys` - `true` for real Tab simulation (default), `false` for legacy method +- `useRealKeys` - **Ignored** (always uses real Tab simulation for accuracy) + +**Note:** The `useRealKeys` parameter is maintained for backward compatibility but is ignored. --- @@ -83,15 +81,17 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr ### `web_keyboard_test_cremotemcp` -**New parameter:** -- `use_real_keys` (boolean, default: `true`) - Use real Tab key simulation +**Parameters:** +- `tab` (string, optional) - Tab ID +- `timeout` (integer, default: 15) - Timeout in seconds + +**Note:** The `use_real_keys` parameter has been removed. Real Tab key simulation is always used. **Example:** ```json { "tool": "web_keyboard_test_cremotemcp", "arguments": { - "use_real_keys": true, "timeout": 15 } } @@ -99,8 +99,14 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr ### `web_keyboard_audit_cremotemcp` -**New parameter:** -- `use_real_keys` (boolean, default: `true`) - Use real Tab key simulation +**Parameters:** +- `tab` (string, optional) - Tab ID +- `check_focus_indicators` (boolean, default: true) +- `check_tab_order` (boolean, default: true) +- `check_keyboard_traps` (boolean, default: true) +- `timeout` (integer, default: 15) - Timeout in seconds + +**Note:** The `use_real_keys` parameter has been removed. Real Tab key simulation is always used. **Example:** ```json @@ -108,7 +114,6 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr "tool": "web_keyboard_audit_cremotemcp", "arguments": { "check_focus_indicators": true, - "use_real_keys": true, "timeout": 15 } } @@ -118,10 +123,13 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr ## Backward Compatibility -✅ **Fully backward compatible** with optional parameter: -- Default behavior: Uses real Tab key simulation (`use_real_keys: true`) -- Legacy behavior: Set `use_real_keys: false` to use programmatic `.focus()` -- Existing code without the parameter will use the new, more accurate method +⚠️ **Breaking Change (Simplified):** +- The `use_real_keys` parameter has been removed from MCP tools +- Client functions still accept the parameter for backward compatibility but ignore it +- **All keyboard testing now uses real Tab key simulation** for accurate results +- Legacy programmatic `.focus()` method has been removed + +**Rationale:** The programmatic method produced false negatives for `:focus-visible` and `:focus-within`, making it unreliable for accessibility testing. --- @@ -132,7 +140,6 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr { "tool": "web_keyboard_audit_cremotemcp", "arguments": { - "use_real_keys": true, "check_focus_indicators": true } } @@ -143,7 +150,7 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr { "tool": "web_keyboard_test_cremotemcp", "arguments": { - "use_real_keys": true + "timeout": 15 } } ``` diff --git a/mcp/main.go b/mcp/main.go index df4eb6b..a776068 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -4883,7 +4883,7 @@ func main() { // Register web_keyboard_test tool mcpServer.AddTool(mcp.Tool{ Name: "web_keyboard_test_cremotemcp", - Description: "Test keyboard navigation and accessibility including tab order, focus indicators, and keyboard traps. Uses real Tab key simulation by default for accurate :focus-within and :focus-visible testing. Automatically detects accessibility plugins (Accessifix, UserWay, AccessiBe, AudioEye, EqualWeb) and provides confidence scoring for focus indicator findings.", + Description: "Test keyboard navigation and accessibility including tab order, focus indicators, and keyboard traps. Uses real Tab key simulation for accurate :focus-within and :focus-visible testing. Automatically detects accessibility plugins (Accessifix, UserWay, AccessiBe, AudioEye, EqualWeb) and provides confidence scoring for focus indicator findings.", InputSchema: mcp.ToolInputSchema{ Type: "object", Properties: map[string]any{ @@ -4891,11 +4891,6 @@ func main() { "type": "string", "description": "Tab ID (optional, uses current tab)", }, - "use_real_keys": map[string]any{ - "type": "boolean", - "description": "Use real Tab key simulation (default: true, recommended for accurate focus-within testing)", - "default": true, - }, "timeout": map[string]any{ "type": "integer", "description": "Timeout in seconds (default: 15)", @@ -4911,10 +4906,9 @@ func main() { } tab := getStringParam(params, "tab", cremoteServer.currentTab) - useRealKeys := getBoolParam(params, "use_real_keys", true) timeout := getIntParam(params, "timeout", 15) - result, err := cremoteServer.client.TestKeyboardNavigation(tab, useRealKeys, timeout) + result, err := cremoteServer.client.TestKeyboardNavigation(tab, true, timeout) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ @@ -5506,7 +5500,7 @@ func main() { // Register web_keyboard_audit tool mcpServer.AddTool(mcp.Tool{ Name: "web_keyboard_audit_cremotemcp", - Description: "Perform keyboard navigation assessment with actionable results. Uses real Tab key simulation by default for accurate :focus-within and :focus-visible testing. Automatically detects accessibility plugins (Accessifix, UserWay, AccessiBe, AudioEye, EqualWeb) and provides confidence scoring for focus indicator findings. Returns summary of issues rather than full element lists, reducing token usage by ~80%.", + Description: "Perform keyboard navigation assessment with actionable results. Uses real Tab key simulation for accurate :focus-within and :focus-visible testing. Automatically detects accessibility plugins (Accessifix, UserWay, AccessiBe, AudioEye, EqualWeb) and provides confidence scoring for focus indicator findings. Returns summary of issues rather than full element lists, reducing token usage by ~80%.", InputSchema: mcp.ToolInputSchema{ Type: "object", Properties: map[string]any{ @@ -5529,11 +5523,6 @@ func main() { "description": "Check for keyboard traps (default: true)", "default": true, }, - "use_real_keys": map[string]any{ - "type": "boolean", - "description": "Use real Tab key simulation (default: true, recommended for accurate focus-within testing)", - "default": true, - }, "timeout": map[string]any{ "type": "integer", "description": "Timeout in seconds (default: 15)", @@ -5552,10 +5541,9 @@ func main() { checkFocusIndicators := getBoolParam(params, "check_focus_indicators", true) checkTabOrder := getBoolParam(params, "check_tab_order", true) checkKeyboardTraps := getBoolParam(params, "check_keyboard_traps", true) - useRealKeys := getBoolParam(params, "use_real_keys", true) timeout := getIntParam(params, "timeout", 15) - result, err := cremoteServer.client.GetKeyboardAudit(tab, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, useRealKeys, timeout) + result, err := cremoteServer.client.GetKeyboardAudit(tab, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, true, timeout) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{