Always use TAB key for keyboard navigation instead of javascript focus()

This commit is contained in:
Josh at WLTechBlog
2025-12-09 07:59:22 -07:00
parent fb94daaef3
commit fb7e07aae9
4 changed files with 53 additions and 304 deletions

View File

@@ -4146,10 +4146,8 @@ func (c *Client) TestKeyboardNavigation(tabID string, useRealKeys bool, timeout
params["tab"] = tabID params["tab"] = tabID
} }
// Add use_real_keys parameter // Note: useRealKeys parameter is ignored - always uses real keyboard simulation
if useRealKeys { // for accurate :focus-visible detection
params["use_real_keys"] = "true"
}
// Add timeout if specified // Add timeout if specified
if timeout > 0 { if timeout > 0 {
@@ -4501,10 +4499,8 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr
params["check_keyboard_traps"] = "true" params["check_keyboard_traps"] = "true"
} }
// Add use_real_keys parameter (default to true for better accuracy) // Note: useRealKeys parameter is ignored - always uses real keyboard simulation
if !useRealKeys { // for accurate :focus-visible detection
params["use_real_keys"] = "false"
}
// Add timeout if specified // Add timeout if specified
if timeout > 0 { if timeout > 0 {

View File

@@ -2062,7 +2062,6 @@ func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) {
case "test-keyboard": case "test-keyboard":
tabID := cmd.Params["tab"] tabID := cmd.Params["tab"]
timeoutStr := cmd.Params["timeout"] 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) // Parse timeout (default to 15 seconds for comprehensive testing)
timeout := 15 timeout := 15
@@ -2072,15 +2071,8 @@ func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) {
} }
} }
var result *KeyboardTestResult // Always use real keyboard simulation for accurate :focus-visible detection
var err error result, err := d.testKeyboardNavigationWithRealKeys(tabID, timeout)
// 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)
}
if err != nil { if err != nil {
response = Response{Success: false, Error: err.Error()} 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" checkFocusIndicators := cmd.Params["check_focus_indicators"] == "true"
checkTabOrder := cmd.Params["check_tab_order"] == "true" checkTabOrder := cmd.Params["check_tab_order"] == "true"
checkKeyboardTraps := cmd.Params["check_keyboard_traps"] == "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"] timeoutStr := cmd.Params["timeout"]
// Parse timeout (default to 15 seconds) // 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 { if err != nil {
response = Response{Success: false, Error: err.Error()} response = Response{Success: false, Error: err.Error()}
} else { } else {
@@ -11484,232 +11476,6 @@ func (d *Daemon) testKeyboardNavigationWithRealKeys(tabID string, timeout int) (
return &initialResult, nil 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 // ZoomTestResult represents the result of zoom level testing
type ZoomTestResult struct { type ZoomTestResult struct {
ZoomLevels []ZoomLevelTest `json:"zoom_levels"` ZoomLevels []ZoomLevelTest `json:"zoom_levels"`
@@ -12678,18 +12444,10 @@ type KeyboardAuditResult struct {
// getKeyboardAudit performs a keyboard navigation assessment // getKeyboardAudit performs a keyboard navigation assessment
func (d *Daemon) getKeyboardAudit(tabID string, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, useRealKeys bool, timeout int) (*KeyboardAuditResult, error) { 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) d.debugLog("Getting keyboard audit for tab: %s", tabID)
// 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)
}
// Always use real keyboard simulation for accurate :focus-visible detection
keyboardResult, err := d.testKeyboardNavigationWithRealKeys(tabID, timeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to test keyboard navigation: %v", err) return nil, fmt.Errorf("failed to test keyboard navigation: %v", err)
} }

View File

@@ -1,21 +1,25 @@
# Real Keyboard Simulation for Focus Indicator Testing # Real Keyboard Simulation for Focus Indicator Testing
**Date:** 2025-11-20 **Date:** 2025-11-20
**Status:****IMPLEMENTED** **Updated:** 2025-12-09
**Status:****IMPLEMENTED** (Now the only method - legacy programmatic method removed)
--- ---
## Overview ## 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 ### 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 ### 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()` ### Client Function: `TestKeyboardNavigation()`
**Old signature:** **Current signature:**
```go
func (c *Client) TestKeyboardNavigation(tabID string, timeout int) (*KeyboardTestResult, error)
```
**New signature:**
```go ```go
func (c *Client) TestKeyboardNavigation(tabID string, useRealKeys bool, timeout int) (*KeyboardTestResult, error) func (c *Client) TestKeyboardNavigation(tabID string, useRealKeys bool, timeout int) (*KeyboardTestResult, error)
``` ```
**Parameters:** **Parameters:**
- `tabID` - Tab ID (empty string uses current tab) - `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 - `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()` ### Client Function: `GetKeyboardAudit()`
**Old signature:** **Current signature:**
```go
func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOrder, checkKeyboardTraps bool, timeout int) (*KeyboardAuditResult, error)
```
**New signature:**
```go ```go
func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, useRealKeys bool, timeout int) (*KeyboardAuditResult, error) func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, useRealKeys bool, timeout int) (*KeyboardAuditResult, error)
``` ```
**Parameters:** **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` ### `web_keyboard_test_cremotemcp`
**New parameter:** **Parameters:**
- `use_real_keys` (boolean, default: `true`) - Use real Tab key simulation - `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:** **Example:**
```json ```json
{ {
"tool": "web_keyboard_test_cremotemcp", "tool": "web_keyboard_test_cremotemcp",
"arguments": { "arguments": {
"use_real_keys": true,
"timeout": 15 "timeout": 15
} }
} }
@@ -99,8 +99,14 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr
### `web_keyboard_audit_cremotemcp` ### `web_keyboard_audit_cremotemcp`
**New parameter:** **Parameters:**
- `use_real_keys` (boolean, default: `true`) - Use real Tab key simulation - `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:** **Example:**
```json ```json
@@ -108,7 +114,6 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr
"tool": "web_keyboard_audit_cremotemcp", "tool": "web_keyboard_audit_cremotemcp",
"arguments": { "arguments": {
"check_focus_indicators": true, "check_focus_indicators": true,
"use_real_keys": true,
"timeout": 15 "timeout": 15
} }
} }
@@ -118,10 +123,13 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr
## Backward Compatibility ## Backward Compatibility
**Fully backward compatible** with optional parameter: ⚠️ **Breaking Change (Simplified):**
- Default behavior: Uses real Tab key simulation (`use_real_keys: true`) - The `use_real_keys` parameter has been removed from MCP tools
- Legacy behavior: Set `use_real_keys: false` to use programmatic `.focus()` - Client functions still accept the parameter for backward compatibility but ignore it
- Existing code without the parameter will use the new, more accurate method - **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", "tool": "web_keyboard_audit_cremotemcp",
"arguments": { "arguments": {
"use_real_keys": true,
"check_focus_indicators": true "check_focus_indicators": true
} }
} }
@@ -143,7 +150,7 @@ func (c *Client) GetKeyboardAudit(tabID string, checkFocusIndicators, checkTabOr
{ {
"tool": "web_keyboard_test_cremotemcp", "tool": "web_keyboard_test_cremotemcp",
"arguments": { "arguments": {
"use_real_keys": true "timeout": 15
} }
} }
``` ```

View File

@@ -4883,7 +4883,7 @@ func main() {
// Register web_keyboard_test tool // Register web_keyboard_test tool
mcpServer.AddTool(mcp.Tool{ mcpServer.AddTool(mcp.Tool{
Name: "web_keyboard_test_cremotemcp", 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{ InputSchema: mcp.ToolInputSchema{
Type: "object", Type: "object",
Properties: map[string]any{ Properties: map[string]any{
@@ -4891,11 +4891,6 @@ func main() {
"type": "string", "type": "string",
"description": "Tab ID (optional, uses current tab)", "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{ "timeout": map[string]any{
"type": "integer", "type": "integer",
"description": "Timeout in seconds (default: 15)", "description": "Timeout in seconds (default: 15)",
@@ -4911,10 +4906,9 @@ func main() {
} }
tab := getStringParam(params, "tab", cremoteServer.currentTab) tab := getStringParam(params, "tab", cremoteServer.currentTab)
useRealKeys := getBoolParam(params, "use_real_keys", true)
timeout := getIntParam(params, "timeout", 15) timeout := getIntParam(params, "timeout", 15)
result, err := cremoteServer.client.TestKeyboardNavigation(tab, useRealKeys, timeout) result, err := cremoteServer.client.TestKeyboardNavigation(tab, true, timeout)
if err != nil { if err != nil {
return &mcp.CallToolResult{ return &mcp.CallToolResult{
Content: []mcp.Content{ Content: []mcp.Content{
@@ -5506,7 +5500,7 @@ func main() {
// Register web_keyboard_audit tool // Register web_keyboard_audit tool
mcpServer.AddTool(mcp.Tool{ mcpServer.AddTool(mcp.Tool{
Name: "web_keyboard_audit_cremotemcp", 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{ InputSchema: mcp.ToolInputSchema{
Type: "object", Type: "object",
Properties: map[string]any{ Properties: map[string]any{
@@ -5529,11 +5523,6 @@ func main() {
"description": "Check for keyboard traps (default: true)", "description": "Check for keyboard traps (default: true)",
"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{ "timeout": map[string]any{
"type": "integer", "type": "integer",
"description": "Timeout in seconds (default: 15)", "description": "Timeout in seconds (default: 15)",
@@ -5552,10 +5541,9 @@ func main() {
checkFocusIndicators := getBoolParam(params, "check_focus_indicators", true) checkFocusIndicators := getBoolParam(params, "check_focus_indicators", true)
checkTabOrder := getBoolParam(params, "check_tab_order", true) checkTabOrder := getBoolParam(params, "check_tab_order", true)
checkKeyboardTraps := getBoolParam(params, "check_keyboard_traps", true) checkKeyboardTraps := getBoolParam(params, "check_keyboard_traps", true)
useRealKeys := getBoolParam(params, "use_real_keys", true)
timeout := getIntParam(params, "timeout", 15) 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 { if err != nil {
return &mcp.CallToolResult{ return &mcp.CallToolResult{
Content: []mcp.Content{ Content: []mcp.Content{