This commit is contained in:
Josh at WLTechBlog
2025-11-20 14:47:55 -07:00
parent 88d8202b0d
commit f8fbfddc7f
5 changed files with 769 additions and 12 deletions

View File

@@ -2062,6 +2062,7 @@ 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
@@ -2071,7 +2072,16 @@ func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) {
}
}
result, err := d.testKeyboardNavigation(tabID, timeout)
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)
}
if err != nil {
response = Response{Success: false, Error: err.Error()}
} else {
@@ -2200,6 +2210,7 @@ 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)
@@ -2210,7 +2221,7 @@ func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) {
}
}
result, err := d.getKeyboardAudit(tabID, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, timeout)
result, err := d.getKeyboardAudit(tabID, checkFocusIndicators, checkTabOrder, checkKeyboardTraps, useRealKeys, timeout)
if err != nil {
response = Response{Success: false, Error: err.Error()}
} else {
@@ -10846,7 +10857,306 @@ type KeyboardTestIssue struct {
Description string `json:"description"`
}
// testKeyboardNavigationWithRealKeys tests keyboard navigation using real Tab key presses
// This properly triggers :focus-within and other CSS pseudo-classes
func (d *Daemon) testKeyboardNavigationWithRealKeys(tabID string, timeout int) (*KeyboardTestResult, error) {
d.debugLog("Testing keyboard navigation with real Tab key simulation for tab: %s", tabID)
page, err := d.getTab(tabID)
if err != nil {
return nil, fmt.Errorf("failed to get page: %v", err)
}
// First, get all interactive elements and their info
jsCode := `() => {
const results = {
total_interactive: 0,
focusable: 0,
not_focusable: 0,
no_focus_indicator: 0,
keyboard_traps: 0,
tab_order: [],
issues: [],
interactive_elements: []
};
// 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();
}
// 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;
// Store info about each interactive element
allInteractive.forEach((element) => {
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 using programmatic focus
let isFocusable = false;
try {
element.focus();
isFocusable = document.activeElement === element;
element.blur();
} catch (e) {
// Element not focusable
}
if (visible) {
if (isFocusable) {
results.focusable++;
results.interactive_elements.push({
selector: selector,
tag_name: tagName,
role: role,
text: text,
tab_index: tabIndex,
is_visible: visible
});
} else {
results.not_focusable++;
results.issues.push({
type: 'not_focusable',
severity: 'high',
element: selector,
description: 'Interactive element is not keyboard focusable'
});
}
}
});
return JSON.stringify(results);
}`
// Execute the initial scan
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 scan interactive elements: %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 scan interactive elements: %w", err)
}
}
// Parse initial results
var initialResult KeyboardTestResult
err = json.Unmarshal([]byte(jsResult.Value.String()), &initialResult)
if err != nil {
return nil, fmt.Errorf("failed to parse initial scan results: %w", err)
}
d.debugLog("Found %d focusable elements, now testing with real Tab key presses", initialResult.Focusable)
// Now use real Tab key presses to test focus indicators
// Focus the body first to start from a known state
_, err = page.Eval(`() => { document.body.focus(); }`)
if err != nil {
d.debugLog("Warning: failed to focus body: %v", err)
}
// Wait a bit for any animations
time.Sleep(100 * time.Millisecond)
// Press Tab key to navigate through focusable elements
tabCount := 0
maxTabs := initialResult.Focusable + 10 // Add buffer for safety
if maxTabs > 200 {
maxTabs = 200 // Cap at 200 to prevent infinite loops
}
for tabCount < maxTabs {
// Press Tab key
err = d.performSpecialKey(tabID, "Tab")
if err != nil {
d.debugLog("Warning: failed to press Tab key: %v", err)
break
}
// Small delay to let focus settle and animations complete
time.Sleep(50 * time.Millisecond)
// Check current focused element and its focus indicator
checkCode := `() => {
const activeEl = document.activeElement;
if (!activeEl || activeEl === document.body || activeEl === document.documentElement) {
return JSON.stringify({ done: true });
}
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();
}
function isVisible(element) {
const style = window.getComputedStyle(element);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
element.offsetWidth > 0 &&
element.offsetHeight > 0;
}
const style = window.getComputedStyle(activeEl);
const selector = getSelector(activeEl);
const visible = isVisible(activeEl);
// Check for visible focus indicator
const hasFocusIndicator = (
(style.outlineWidth && parseFloat(style.outlineWidth) > 0 && style.outlineStyle !== 'none') ||
(style.boxShadow && style.boxShadow !== 'none') ||
(style.border && style.borderWidth && parseFloat(style.borderWidth) > 0)
);
return JSON.stringify({
done: false,
selector: selector,
tag_name: activeEl.tagName.toLowerCase(),
role: activeEl.getAttribute('role') || '',
text: activeEl.textContent.trim().substring(0, 50),
tab_index: activeEl.tabIndex,
has_focus_indicator: hasFocusIndicator,
is_visible: visible,
outline_width: style.outlineWidth,
box_shadow: style.boxShadow,
border_width: style.borderWidth
});
}`
checkResult, err := page.Eval(checkCode)
if err != nil {
d.debugLog("Warning: failed to check focused element: %v", err)
break
}
var focusInfo struct {
Done bool `json:"done"`
Selector string `json:"selector"`
TagName string `json:"tag_name"`
Role string `json:"role"`
Text string `json:"text"`
TabIndex int `json:"tab_index"`
HasFocusIndicator bool `json:"has_focus_indicator"`
IsVisible bool `json:"is_visible"`
OutlineWidth string `json:"outline_width"`
BoxShadow string `json:"box_shadow"`
BorderWidth string `json:"border_width"`
}
err = json.Unmarshal([]byte(checkResult.Value.String()), &focusInfo)
if err != nil {
d.debugLog("Warning: failed to parse focus info: %v", err)
break
}
// If we've cycled back to body/document, we're done
if focusInfo.Done {
break
}
// Add to tab order
initialResult.TabOrder = append(initialResult.TabOrder, KeyboardTestElement{
Index: len(initialResult.TabOrder),
Selector: focusInfo.Selector,
TagName: focusInfo.TagName,
Role: focusInfo.Role,
Text: focusInfo.Text,
TabIndex: focusInfo.TabIndex,
HasFocusStyle: focusInfo.HasFocusIndicator,
IsVisible: focusInfo.IsVisible,
})
// Track elements without focus indicators
if !focusInfo.HasFocusIndicator && focusInfo.IsVisible {
initialResult.NoFocusIndicator++
initialResult.Issues = append(initialResult.Issues, KeyboardTestIssue{
Type: "no_focus_indicator",
Severity: "high",
Element: focusInfo.Selector,
Description: "Interactive element lacks visible focus indicator",
})
}
tabCount++
// Safety check: if we've found all expected focusable elements, stop
if len(initialResult.TabOrder) >= initialResult.Focusable {
break
}
}
d.debugLog("Completed keyboard navigation test: %d elements in tab order, %d without focus indicators",
len(initialResult.TabOrder), initialResult.NoFocusIndicator)
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)
@@ -12036,11 +12346,19 @@ type KeyboardAuditResult struct {
}
// 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)
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)
}
// Run keyboard navigation test
keyboardResult, err := d.testKeyboardNavigation(tabID, timeout)
if err != nil {
return nil, fmt.Errorf("failed to test keyboard navigation: %v", err)
}