mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-02-28 00:22:41 -08:00
293 lines
10 KiB
Python
Executable File
293 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
iOS Simulator Accessibility Audit
|
|
|
|
Scans the current simulator screen for accessibility compliance issues.
|
|
Optimized for minimal token output while maintaining functionality.
|
|
|
|
Usage: python scripts/accessibility_audit.py [options]
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import asdict, dataclass
|
|
from typing import Any
|
|
|
|
from common import flatten_tree, get_accessibility_tree, resolve_udid
|
|
|
|
|
|
@dataclass
|
|
class Issue:
|
|
"""Represents an accessibility issue."""
|
|
|
|
severity: str # critical, warning, info
|
|
rule: str
|
|
element_type: str
|
|
issue: str
|
|
fix: str
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Convert to dictionary for JSON serialization."""
|
|
return asdict(self)
|
|
|
|
|
|
class AccessibilityAuditor:
|
|
"""Performs accessibility audits on iOS simulator screens."""
|
|
|
|
# Critical rules that block users
|
|
CRITICAL_RULES = {
|
|
"missing_label": lambda e: e.get("type") in ["Button", "Link"] and not e.get("AXLabel"),
|
|
"empty_button": lambda e: e.get("type") == "Button"
|
|
and not (e.get("AXLabel") or e.get("AXValue")),
|
|
"image_no_alt": lambda e: e.get("type") == "Image" and not e.get("AXLabel"),
|
|
}
|
|
|
|
# Warnings that degrade UX
|
|
WARNING_RULES = {
|
|
"missing_hint": lambda e: e.get("type") in ["Slider", "TextField"] and not e.get("help"),
|
|
"missing_traits": lambda e: e.get("type") and not e.get("traits"),
|
|
}
|
|
|
|
# Info level suggestions
|
|
INFO_RULES = {
|
|
"no_identifier": lambda e: not e.get("AXUniqueId"),
|
|
"deep_nesting": lambda e: e.get("depth", 0) > 5,
|
|
}
|
|
|
|
def __init__(self, udid: str | None = None):
|
|
"""Initialize auditor with optional device UDID."""
|
|
self.udid = udid
|
|
|
|
def get_accessibility_tree(self) -> dict:
|
|
"""Fetch accessibility tree from simulator using shared utility."""
|
|
return get_accessibility_tree(self.udid, nested=True)
|
|
|
|
@staticmethod
|
|
def _is_small_target(element: dict) -> bool:
|
|
"""Check if touch target is too small (< 44x44 points)."""
|
|
frame = element.get("frame", {})
|
|
width = frame.get("width", 0)
|
|
height = frame.get("height", 0)
|
|
return width < 44 or height < 44
|
|
|
|
def _flatten_tree(self, node: dict, depth: int = 0) -> list[dict]:
|
|
"""Flatten nested accessibility tree for easier processing using shared utility."""
|
|
return flatten_tree(node, depth)
|
|
|
|
def audit_element(self, element: dict) -> list[Issue]:
|
|
"""Audit a single element for accessibility issues."""
|
|
issues = []
|
|
|
|
# Check critical rules
|
|
for rule_name, rule_func in self.CRITICAL_RULES.items():
|
|
if rule_func(element):
|
|
issues.append(
|
|
Issue(
|
|
severity="critical",
|
|
rule=rule_name,
|
|
element_type=element.get("type", "Unknown"),
|
|
issue=self._get_issue_description(rule_name),
|
|
fix=self._get_fix_suggestion(rule_name),
|
|
)
|
|
)
|
|
|
|
# Check warnings (skip if critical issues found)
|
|
if not issues:
|
|
for rule_name, rule_func in self.WARNING_RULES.items():
|
|
if rule_func(element):
|
|
issues.append(
|
|
Issue(
|
|
severity="warning",
|
|
rule=rule_name,
|
|
element_type=element.get("type", "Unknown"),
|
|
issue=self._get_issue_description(rule_name),
|
|
fix=self._get_fix_suggestion(rule_name),
|
|
)
|
|
)
|
|
|
|
# Check info level (only if verbose or no other issues)
|
|
if not issues:
|
|
for rule_name, rule_func in self.INFO_RULES.items():
|
|
if rule_func(element):
|
|
issues.append(
|
|
Issue(
|
|
severity="info",
|
|
rule=rule_name,
|
|
element_type=element.get("type", "Unknown"),
|
|
issue=self._get_issue_description(rule_name),
|
|
fix=self._get_fix_suggestion(rule_name),
|
|
)
|
|
)
|
|
|
|
return issues
|
|
|
|
def _get_issue_description(self, rule: str) -> str:
|
|
"""Get human-readable issue description."""
|
|
descriptions = {
|
|
"missing_label": "Interactive element missing accessibility label",
|
|
"empty_button": "Button has no text or label",
|
|
"image_no_alt": "Image missing alternative text",
|
|
"missing_hint": "Complex control missing hint",
|
|
"small_touch_target": "Touch target smaller than 44x44pt",
|
|
"missing_traits": "Element missing accessibility traits",
|
|
"no_identifier": "Missing accessibility identifier",
|
|
"deep_nesting": "Deeply nested (>5 levels)",
|
|
}
|
|
return descriptions.get(rule, "Accessibility issue")
|
|
|
|
def _get_fix_suggestion(self, rule: str) -> str:
|
|
"""Get fix suggestion for issue."""
|
|
fixes = {
|
|
"missing_label": "Add accessibilityLabel",
|
|
"empty_button": "Set button title or accessibilityLabel",
|
|
"image_no_alt": "Add accessibilityLabel with description",
|
|
"missing_hint": "Add accessibilityHint",
|
|
"small_touch_target": "Increase to minimum 44x44pt",
|
|
"missing_traits": "Set appropriate accessibilityTraits",
|
|
"no_identifier": "Add accessibilityIdentifier for testing",
|
|
"deep_nesting": "Simplify view hierarchy",
|
|
}
|
|
return fixes.get(rule, "Review accessibility")
|
|
|
|
def audit(self, verbose: bool = False) -> dict[str, Any]:
|
|
"""Perform full accessibility audit."""
|
|
# Get accessibility tree
|
|
tree = self.get_accessibility_tree()
|
|
|
|
# Flatten for processing
|
|
elements = self._flatten_tree(tree)
|
|
|
|
# Audit each element
|
|
all_issues = []
|
|
for element in elements:
|
|
issues = self.audit_element(element)
|
|
for issue in issues:
|
|
issue_dict = issue.to_dict()
|
|
# Add minimal element info for context
|
|
issue_dict["element"] = {
|
|
"type": element.get("type", "Unknown"),
|
|
"label": element.get("AXLabel", "")[:30] if element.get("AXLabel") else None,
|
|
}
|
|
all_issues.append(issue_dict)
|
|
|
|
# Count by severity
|
|
critical = len([i for i in all_issues if i["severity"] == "critical"])
|
|
warning = len([i for i in all_issues if i["severity"] == "warning"])
|
|
info = len([i for i in all_issues if i["severity"] == "info"])
|
|
|
|
# Build result (token-optimized)
|
|
result = {
|
|
"summary": {
|
|
"total": len(elements),
|
|
"issues": len(all_issues),
|
|
"critical": critical,
|
|
"warning": warning,
|
|
"info": info,
|
|
}
|
|
}
|
|
|
|
if verbose:
|
|
# Full details only if requested
|
|
result["issues"] = all_issues
|
|
else:
|
|
# Default: top issues only (token-efficient)
|
|
result["top_issues"] = self._get_top_issues(all_issues)
|
|
|
|
return result
|
|
|
|
def _get_top_issues(self, issues: list[dict]) -> list[dict]:
|
|
"""Get top 3 issues grouped by type (token-efficient)."""
|
|
if not issues:
|
|
return []
|
|
|
|
# Group by rule
|
|
grouped = {}
|
|
for issue in issues:
|
|
rule = issue["rule"]
|
|
if rule not in grouped:
|
|
grouped[rule] = {
|
|
"severity": issue["severity"],
|
|
"rule": rule,
|
|
"count": 0,
|
|
"fix": issue["fix"],
|
|
}
|
|
grouped[rule]["count"] += 1
|
|
|
|
# Sort by severity and count
|
|
severity_order = {"critical": 0, "warning": 1, "info": 2}
|
|
sorted_issues = sorted(
|
|
grouped.values(), key=lambda x: (severity_order[x["severity"]], -x["count"])
|
|
)
|
|
|
|
return sorted_issues[:3]
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Audit iOS simulator screen for accessibility issues"
|
|
)
|
|
parser.add_argument(
|
|
"--udid",
|
|
help="Device UDID (auto-detects booted simulator if not provided)",
|
|
)
|
|
parser.add_argument("--output", help="Save JSON report to file")
|
|
parser.add_argument(
|
|
"--verbose", action="store_true", help="Include all issue details (increases output)"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Resolve UDID with auto-detection
|
|
try:
|
|
udid = resolve_udid(args.udid)
|
|
except RuntimeError as e:
|
|
print(f"Error: {e}")
|
|
sys.exit(1)
|
|
|
|
# Perform audit
|
|
auditor = AccessibilityAuditor(udid=udid)
|
|
|
|
try:
|
|
result = auditor.audit(verbose=args.verbose)
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
sys.exit(1)
|
|
|
|
# Output results
|
|
if args.output:
|
|
# Save to file
|
|
with open(args.output, "w") as f:
|
|
json.dump(result, f, indent=2)
|
|
# Print minimal summary
|
|
summary = result["summary"]
|
|
print(f"Audit complete: {summary['issues']} issues ({summary['critical']} critical)")
|
|
print(f"Report saved to: {args.output}")
|
|
# Print to stdout (token-optimized by default)
|
|
elif args.verbose:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
# Ultra-compact output
|
|
summary = result["summary"]
|
|
print(f"Elements: {summary['total']}, Issues: {summary['issues']}")
|
|
print(
|
|
f"Critical: {summary['critical']}, Warning: {summary['warning']}, Info: {summary['info']}"
|
|
)
|
|
|
|
if result.get("top_issues"):
|
|
print("\nTop issues:")
|
|
for issue in result["top_issues"]:
|
|
print(
|
|
f" [{issue['severity']}] {issue['rule']} ({issue['count']}x) - {issue['fix']}"
|
|
)
|
|
|
|
# Exit with error if critical issues found
|
|
if result["summary"]["critical"] > 0:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|