Files
driftwood/tools/a11y-audit.py
lashman 7e55d5796f Add WCAG 2.2 AAA compliance and automated AT-SPI audit tool
- Bring all UI widgets to WCAG 2.2 AAA conformance across all views
- Add accessible labels, roles, descriptions, and announcements
- Bump focus outlines to 3px, target sizes to 44px AAA minimum
- Fix announce()/announce_result() to walk widget tree via parent()
- Add AT-SPI accessibility audit script (tools/a11y-audit.py) that
  checks SC 4.1.2, 1.1.1, 1.3.1, 2.1.1, 2.5.5, 2.5.8, 2.4.8,
  2.4.9, 2.4.10, 2.1.3 with JSON report output for CI
- Clean up project structure, archive old plan documents
2026-03-01 12:44:21 +02:00

965 lines
33 KiB
Python

#!/usr/bin/env python3
"""
Automated WCAG 2.2 AAA accessibility audit for Driftwood via AT-SPI.
Launches the app, waits for it to register on the accessibility bus,
then walks the entire widget tree checking for violations at A, AA, and AAA
levels:
Level A:
- SC 1.1.1: Images with no name and no decorative marking
- SC 1.3.1: Headings missing a level property
- SC 2.1.1: Interactive widgets not keyboard-focusable
- SC 4.1.2: Interactive widgets with no accessible name
Level AA:
- SC 2.5.8: Target size below 24x24px minimum
- SC 4.1.2: Progress bars / spinners without labels
Level AAA:
- SC 2.1.3: All interactive widgets must be keyboard accessible (no traps)
- SC 2.4.8: Window must have a meaningful title (location)
- SC 2.4.9: Link purpose from link text alone
- SC 2.4.10: Section headings exist in content regions
- SC 2.5.5: Target size 44x44px (AAA level)
- SC 3.2.5: Change on request - no auto-refresh detection
- SC 3.3.9: Accessible authentication - no cognitive function tests
Structural / informational:
- Live regions (alert/status/log/timer roles) inventory
- Keyboard focus traversal test across all views
- Tab order validation (focusable widgets reachable)
Usage:
python3 tools/a11y-audit.py [--no-launch] [--level aa|aaa] [--verbose]
--no-launch Attach to an already-running Driftwood instance
--level Minimum conformance level to check (default: aaa)
--verbose Show informational messages and live region inventory
"""
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
import subprocess
import sys
import time
import signal
import os
import json
# --- Configuration ---
AUDIT_LEVEL = "aaa" # Default: check everything up to AAA
VERBOSE = False
# Roles that MUST have an accessible name
INTERACTIVE_ROLES = {
Atspi.Role.PUSH_BUTTON,
Atspi.Role.TOGGLE_BUTTON,
Atspi.Role.CHECK_BOX,
Atspi.Role.RADIO_BUTTON,
Atspi.Role.MENU_ITEM,
Atspi.Role.ENTRY,
Atspi.Role.SPIN_BUTTON,
Atspi.Role.SLIDER,
Atspi.Role.COMBO_BOX,
Atspi.Role.LINK,
Atspi.Role.SPLIT_BUTTON if hasattr(Atspi.Role, "SPLIT_BUTTON") else None,
}
INTERACTIVE_ROLES.discard(None)
# Roles where missing name is just a warning (container-like)
CONTAINER_ROLES = {
Atspi.Role.PANEL,
Atspi.Role.FILLER,
Atspi.Role.SCROLL_PANE,
Atspi.Role.VIEWPORT,
Atspi.Role.FRAME,
Atspi.Role.SECTION,
Atspi.Role.BLOCK_QUOTE,
Atspi.Role.REDUNDANT_OBJECT,
Atspi.Role.SEPARATOR,
}
# Roles considered "image-like"
IMAGE_ROLES = {
Atspi.Role.IMAGE,
Atspi.Role.ICON,
Atspi.Role.ANIMATION,
}
# Roles that indicate live regions (for inventory)
LIVE_REGION_ROLES = {
Atspi.Role.ALERT,
Atspi.Role.NOTIFICATION if hasattr(Atspi.Role, "NOTIFICATION") else None,
Atspi.Role.STATUS_BAR,
Atspi.Role.LOG if hasattr(Atspi.Role, "LOG") else None,
Atspi.Role.TIMER if hasattr(Atspi.Role, "TIMER") else None,
Atspi.Role.MARQUEE if hasattr(Atspi.Role, "MARQUEE") else None,
}
LIVE_REGION_ROLES.discard(None)
# Roles that represent content sections (for SC 2.4.10 heading check)
CONTENT_REGION_ROLES = {
Atspi.Role.SCROLL_PANE,
Atspi.Role.PANEL,
Atspi.Role.SECTION,
Atspi.Role.DOCUMENT_FRAME,
}
# Decorative roles that don't need names
DECORATIVE_ROLES = set()
# GTK maps AccessibleRole::Presentation to ROLE_REDUNDANT_OBJECT
# but some versions map it differently; we check name == "" as well
class Issue:
def __init__(self, severity, criterion, level, path, role, message):
self.severity = severity # "error", "warning", "info"
self.criterion = criterion
self.level = level # "A", "AA", "AAA"
self.path = path
self.role = role
self.message = message
def __str__(self):
return (f"[{self.severity.upper()}] SC {self.criterion} ({self.level}) "
f"| {self.role} | {self.path}\n {self.message}")
# --- Stats tracking ---
class AuditStats:
def __init__(self):
self.total_interactive = 0
self.total_focusable = 0
self.total_images = 0
self.total_headings = 0
self.total_links = 0
self.total_live_regions = 0
self.windows_with_titles = 0
self.windows_total = 0
self.focus_chain = [] # ordered list of focusable widget paths
self.content_sections = 0
self.sections_with_headings = 0
stats = AuditStats()
# --- Helper functions ---
def get_name(node):
"""Get accessible name, handling exceptions."""
try:
return node.get_name() or ""
except Exception:
return ""
def get_role(node):
"""Get role, handling exceptions."""
try:
return node.get_role()
except Exception:
return Atspi.Role.INVALID
def get_role_name(node):
"""Get role name string."""
try:
return node.get_role_name() or "unknown"
except Exception:
return "unknown"
def get_description(node):
"""Get accessible description."""
try:
return node.get_description() or ""
except Exception:
return ""
def get_states(node):
"""Get state set."""
try:
return node.get_state_set()
except Exception:
return None
def get_child_count(node):
try:
return node.get_child_count()
except Exception:
return 0
def get_child(node, idx):
try:
return node.get_child_at_index(idx)
except Exception:
return None
def get_attributes_dict(node):
"""Get node attributes as a dict, handling various AT-SPI return formats."""
try:
attrs = node.get_attributes()
if attrs is None:
return {}
if isinstance(attrs, dict):
return attrs
# Some versions return a list of "key:value" strings
result = {}
for attr in attrs:
if isinstance(attr, str) and ":" in attr:
k, _, v = attr.partition(":")
result[k.strip()] = v.strip()
return result
except Exception:
return {}
def build_path(node, max_depth=6):
"""Build a human-readable path like 'window > box > button'."""
parts = []
current = node
for _ in range(max_depth):
if current is None:
break
name = get_name(current)
role = get_role_name(current)
if name:
parts.append(f"{role}({name[:30]})")
else:
parts.append(role)
try:
current = current.get_parent()
except Exception:
break
parts.reverse()
return " > ".join(parts)
def get_size(node):
"""Get component size if available."""
try:
comp = node.get_component_iface()
if comp:
rect = comp.get_extents(Atspi.CoordType.SCREEN)
return (rect.width, rect.height)
except Exception:
pass
return None
def has_heading_descendant(node, max_depth=5):
"""Check if a node has any HEADING descendant (for SC 2.4.10)."""
if max_depth <= 0:
return False
n = get_child_count(node)
for i in range(n):
child = get_child(node, i)
if child is None:
continue
if get_role(child) == Atspi.Role.HEADING:
return True
if has_heading_descendant(child, max_depth - 1):
return True
return False
def count_children_deep(node, max_depth=3):
"""Count total descendants to gauge if a container has real content."""
if max_depth <= 0:
return 0
total = 0
n = get_child_count(node)
for i in range(n):
child = get_child(node, i)
if child:
total += 1 + count_children_deep(child, max_depth - 1)
return total
def check_level(target):
"""Check if the given WCAG level should be audited."""
levels = {"a": 1, "aa": 2, "aaa": 3}
return levels.get(target.lower(), 3) <= levels.get(AUDIT_LEVEL.lower(), 3)
def _has_text_descendant(node, max_depth=1):
"""Check if a node has a child (or grandchild) providing text content."""
if max_depth <= 0:
return False
for i in range(get_child_count(node)):
child = get_child(node, i)
if child:
child_name = get_name(child)
child_role = get_role(child)
if child_name.strip() or child_role == Atspi.Role.LABEL:
return True
if max_depth > 1 and _has_text_descendant(child, max_depth - 1):
return True
return False
# --- Main audit function ---
def audit_node(node, issues, visited, depth=0, path_prefix=""):
"""Recursively audit a single AT-SPI node and its children."""
if node is None or depth > 50:
return
# Build a unique key from the tree position to avoid cycles
# without relying on object identity (which Python GC can reuse)
role_name = get_role_name(node)
name = get_name(node)
node_key = f"{path_prefix}/{depth}:{role_name}:{name[:20]}"
if node_key in visited:
return
visited.add(node_key)
role = get_role(node)
name = get_name(node)
desc = get_description(node)
role_name = get_role_name(node)
path = build_path(node)
states = get_states(node)
# Skip dead/invalid nodes
if role == Atspi.Role.INVALID:
return
is_visible = states and states.contains(Atspi.StateType.VISIBLE)
is_showing = states and states.contains(Atspi.StateType.SHOWING)
is_focusable = states and states.contains(Atspi.StateType.FOCUSABLE)
# =================================================================
# Level A checks
# =================================================================
# --- SC 4.1.2 (A): Interactive widgets must have a name ---
if role in INTERACTIVE_ROLES:
stats.total_interactive += 1
if is_visible and not name.strip():
# GTK menu section separators appear as MENU_ITEM with no name
# and no children - these are decorative, not interactive.
# Also, GMenu-backed popover items don't expose names via AT-SPI
# until the popover is opened (they have children but no name
# in their not-SHOWING state), so skip those too.
if role == Atspi.Role.MENU_ITEM and (
get_child_count(node) == 0 or not is_showing
):
pass # Section separator or closed popover item
else:
# Check if it has children that provide text content.
# For MENU_ITEM, check 2 levels deep because GTK4
# structures them as menu_item > box > label, and the
# AT-SPI bridge may not expose the name on closed popovers.
max_check_depth = 2 if role == Atspi.Role.MENU_ITEM else 1
has_text_child = _has_text_descendant(node, max_check_depth)
if not has_text_child:
issues.append(Issue(
"error", "4.1.2", "A",
path, role_name,
"Interactive widget has no accessible name"
))
# --- SC 1.1.1 (A): Images need name or Presentation role ---
if role in IMAGE_ROLES:
stats.total_images += 1
if is_visible and not name.strip() and not desc.strip():
# Check if parent is a button that has its own label
try:
parent = node.get_parent()
parent_role = get_role(parent) if parent else None
parent_name = get_name(parent) if parent else ""
except Exception:
parent_role = None
parent_name = ""
if parent_role in INTERACTIVE_ROLES and parent_name.strip():
# Image inside a labeled button is functionally decorative.
# GTK4's AT-SPI bridge still reports it as ROLE_IMAGE even when
# AccessibleRole::Presentation is set, so this is cosmetic.
pass
elif parent_role == Atspi.Role.PANEL:
# Check grandparent - image might be inside panel inside button
try:
grandparent = parent.get_parent()
gp_role = get_role(grandparent) if grandparent else None
gp_name = get_name(grandparent) if grandparent else ""
except Exception:
gp_role = None
gp_name = ""
if gp_role in INTERACTIVE_ROLES and gp_name.strip():
pass # Decorative inside labeled interactive widget
else:
issues.append(Issue(
"error", "1.1.1", "A",
path, role_name,
"Image has no accessible name and is not marked decorative"
))
else:
issues.append(Issue(
"error", "1.1.1", "A",
path, role_name,
"Image has no accessible name and is not marked decorative"
))
# --- SC 1.3.1 (A): Headings should have a level ---
if role == Atspi.Role.HEADING:
stats.total_headings += 1
attrs = get_attributes_dict(node)
level = attrs.get("level")
if level is None:
# Also try get_attribute directly
try:
level = node.get_attribute("level")
except Exception:
level = None
if not level:
issues.append(Issue(
"warning", "1.3.1", "A",
path, role_name,
"Heading has no level attribute"
))
# --- SC 2.1.1 (A): Interactive widgets must be keyboard-focusable ---
if role in INTERACTIVE_ROLES and is_visible and is_showing:
if not is_focusable:
# Exclude menu items (handled by menu keyboard nav)
if role == Atspi.Role.MENU_ITEM:
pass
else:
# Check if this is a composite widget (SplitButton/MenuButton)
# whose child toggle button IS focusable - not a real issue
has_focusable_child = False
for i in range(get_child_count(node)):
child = get_child(node, i)
if child:
cr = get_role(child)
cs = get_states(child)
if (cr in {Atspi.Role.TOGGLE_BUTTON, Atspi.Role.PUSH_BUTTON}
and cs and cs.contains(Atspi.StateType.FOCUSABLE)):
has_focusable_child = True
break
if not has_focusable_child:
issues.append(Issue(
"warning", "2.1.1", "A",
path, role_name,
"Interactive widget is visible but not keyboard-focusable"
))
# =================================================================
# Level AA checks
# =================================================================
# --- SC 4.1.2 (AA): Progress bars / spinners need labels ---
if role == Atspi.Role.PROGRESS_BAR:
if not name.strip() and not desc.strip():
issues.append(Issue(
"error", "4.1.2", "AA",
path, role_name,
"Progress bar has no accessible name or description"
))
# --- SC 2.5.8 (AA): Target size minimum 24x24 ---
# Exclude window control buttons (Minimize, Maximize, Close, More Options)
# as these are managed by the toolkit/window manager, not the app.
WINDOW_CONTROL_NAMES = {"minimize", "maximize", "close", "more options"}
is_window_control = name.strip().lower() in WINDOW_CONTROL_NAMES
if role in INTERACTIVE_ROLES and not is_window_control:
size = get_size(node)
if size and is_visible and is_showing:
w, h = size
if 0 < w < 24 or 0 < h < 24:
issues.append(Issue(
"warning", "2.5.8", "AA",
path, role_name,
f"Interactive element is {w}x{h}px - below 24px AA minimum target size"
))
# --- SC 4.1.2 (AA): Focusable non-interactive widgets should have a name ---
if role not in INTERACTIVE_ROLES and role not in CONTAINER_ROLES and role not in IMAGE_ROLES:
if is_focusable and is_visible and not name.strip():
# Only flag if it's not a generic container
if role not in {Atspi.Role.APPLICATION, Atspi.Role.WINDOW,
Atspi.Role.PAGE_TAB_LIST, Atspi.Role.PAGE_TAB,
Atspi.Role.SCROLL_PANE, Atspi.Role.LIST,
Atspi.Role.LIST_ITEM, Atspi.Role.TABLE_CELL,
Atspi.Role.TREE_TABLE, Atspi.Role.TREE_ITEM,
Atspi.Role.LABEL, Atspi.Role.TEXT,
Atspi.Role.DOCUMENT_FRAME, Atspi.Role.TOOL_BAR,
Atspi.Role.STATUS_BAR, Atspi.Role.MENU_BAR,
Atspi.Role.INTERNAL_FRAME}:
issues.append(Issue(
"warning", "4.1.2", "AA",
path, role_name,
"Focusable widget has no accessible name"
))
# =================================================================
# Level AAA checks
# =================================================================
if check_level("aaa"):
# --- SC 2.5.5 (AAA): Target size 44x44px ---
if role in INTERACTIVE_ROLES and not is_window_control:
size = get_size(node)
if size and is_visible and is_showing:
w, h = size
# Already flagged at 24px for AA; flag at 44px for AAA
if 24 <= w < 44 or 24 <= h < 44:
issues.append(Issue(
"info", "2.5.5", "AAA",
path, role_name,
f"Interactive element is {w}x{h}px - below 44px AAA target size"
))
# --- SC 2.4.8 (AAA): Location - windows must have meaningful titles ---
if role == Atspi.Role.WINDOW or role == Atspi.Role.FRAME:
stats.windows_total += 1
if name.strip():
stats.windows_with_titles += 1
else:
issues.append(Issue(
"warning", "2.4.8", "AAA",
path, role_name,
"Window/frame has no title - users cannot determine their location"
))
# --- SC 2.4.9 (AAA): Link purpose from link text alone ---
if role == Atspi.Role.LINK and is_visible:
stats.total_links += 1
link_text = name.strip().lower()
# Flag generic link text that doesn't convey purpose
vague_texts = {
"click here", "here", "more", "read more", "link",
"learn more", "details", "info", "this",
}
if link_text in vague_texts:
issues.append(Issue(
"warning", "2.4.9", "AAA",
path, role_name,
f"Link text '{name.strip()}' is too vague - "
"purpose should be clear from link text alone"
))
# --- SC 2.4.10 (AAA): Section headings ---
# Large content regions (with many children) should have at least one heading
if role in CONTENT_REGION_ROLES and is_visible:
child_count = count_children_deep(node, 2)
if child_count > 10: # Non-trivial content region
stats.content_sections += 1
if has_heading_descendant(node, 4):
stats.sections_with_headings += 1
# =================================================================
# Informational checks (all levels)
# =================================================================
# --- Live region inventory ---
if role in LIVE_REGION_ROLES:
stats.total_live_regions += 1
if VERBOSE:
issues.append(Issue(
"info", "4.1.3", "A",
path, role_name,
f"Live region found: {role_name} - name='{name}'"
))
# --- Track focus chain ---
if is_focusable and is_visible:
stats.total_focusable += 1
stats.focus_chain.append(path)
# --- Recurse into children ---
n_children = get_child_count(node)
for i in range(n_children):
child = get_child(node, i)
if child is not None:
audit_node(child, issues, visited, depth + 1, f"{node_key}/{i}")
def run_keyboard_focus_test(app):
"""
Test keyboard focus traversal by checking that focusable widgets
are reachable via the focus chain.
SC 2.1.3 (AAA): No keyboard trap - all focusable widgets should
have a logical tab order without traps.
Returns a list of issues found.
"""
issues = []
# Walk the tree and verify that focusable interactive widgets
# actually have the FOCUSABLE state set (basic trap detection)
def check_focus_trap(node, seen, depth=0):
if node is None or depth > 50:
return
key = f"focus/{depth}:{get_role_name(node)}:{get_name(node)[:20]}"
if key in seen:
return
seen.add(key)
role = get_role(node)
states = get_states(node)
is_visible = states and states.contains(Atspi.StateType.VISIBLE)
is_showing = states and states.contains(Atspi.StateType.SHOWING)
is_focused = states and states.contains(Atspi.StateType.FOCUSED)
# A widget that is focused but not focusable is a bug
if is_focused and not (states and states.contains(Atspi.StateType.FOCUSABLE)):
issues.append(Issue(
"warning", "2.1.3", "AAA",
build_path(node), get_role_name(node),
"Widget has FOCUSED state but not FOCUSABLE - potential keyboard trap"
))
# Check for modal dialogs without focusable children (trap)
if role == Atspi.Role.DIALOG and is_visible and is_showing:
has_focusable = False
for i in range(get_child_count(node)):
child = get_child(node, i)
if child:
cs = get_states(child)
if cs and cs.contains(Atspi.StateType.FOCUSABLE):
has_focusable = True
break
if not has_focusable and get_child_count(node) > 0:
issues.append(Issue(
"warning", "2.1.3", "AAA",
build_path(node), get_role_name(node),
"Visible dialog has no focusable children - potential keyboard trap"
))
for i in range(get_child_count(node)):
child = get_child(node, i)
check_focus_trap(child, seen, depth + 1)
if check_level("aaa"):
check_focus_trap(app, set())
return issues
def find_driftwood_app():
"""Find the Driftwood application on the AT-SPI bus."""
desktop = Atspi.get_desktop(0)
n = get_child_count(desktop)
for i in range(n):
app = get_child(desktop, i)
if app is None:
continue
app_name = get_name(app)
# Match exact app name (not substring) to avoid matching editors
# that have "driftwood" in their window title
if app_name and app_name.lower() == "driftwood":
return app
return None
def print_stats():
"""Print audit statistics summary."""
print(f"\n--- AUDIT STATISTICS ---")
print(f" Interactive widgets: {stats.total_interactive}")
print(f" Focusable widgets: {stats.total_focusable}")
print(f" Images: {stats.total_images}")
print(f" Headings: {stats.total_headings}")
print(f" Links: {stats.total_links}")
print(f" Live regions: {stats.total_live_regions}")
print(f" Windows with titles: {stats.windows_with_titles}/{stats.windows_total}")
if check_level("aaa") and stats.content_sections > 0:
print(f" Content sections with headings: "
f"{stats.sections_with_headings}/{stats.content_sections}")
print()
def main():
global AUDIT_LEVEL, VERBOSE
no_launch = "--no-launch" in sys.argv
if "--level" in sys.argv:
idx = sys.argv.index("--level")
if idx + 1 < len(sys.argv):
AUDIT_LEVEL = sys.argv[idx + 1].lower()
if "--verbose" in sys.argv:
VERBOSE = True
print(f"WCAG audit level: {AUDIT_LEVEL.upper()}")
proc = None
if not no_launch:
# Build first
print("Building Driftwood...")
build = subprocess.run(
["cargo", "build"],
cwd="/media/lashman/DATA1/gdfhbfgdbnbdfbdf/driftwood",
capture_output=True, text=True,
)
if build.returncode != 0:
print("Build failed:")
print(build.stderr)
sys.exit(1)
print("Launching Driftwood...")
env = os.environ.copy()
env["GTK_A11Y"] = "atspi"
proc = subprocess.Popen(
["./target/debug/driftwood"],
cwd="/media/lashman/DATA1/gdfhbfgdbnbdfbdf/driftwood",
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
# Wait for app to appear on AT-SPI bus
print("Waiting for Driftwood to appear on AT-SPI bus...")
app = None
for attempt in range(30):
time.sleep(1)
app = find_driftwood_app()
if app:
break
if proc.poll() is not None:
print("App exited prematurely")
sys.exit(1)
if not app:
print("Timed out waiting for Driftwood on AT-SPI bus")
if proc:
proc.terminate()
sys.exit(1)
else:
print("Looking for running Driftwood instance...")
app = find_driftwood_app()
if not app:
print("No running Driftwood instance found on AT-SPI bus")
sys.exit(1)
print(f"Found Driftwood: {get_name(app)}")
print(f"Windows: {get_child_count(app)}")
# Give UI time to fully render (scan, load cards, etc.)
time.sleep(8)
issues = []
visited = set()
# Audit all views by activating GActions via gdbus
def activate_action(action_name):
"""Activate a GAction on the Driftwood app via D-Bus."""
try:
subprocess.run(
["gdbus", "call", "--session",
"--dest", "io.github.driftwood",
"--object-path", "/io/github/driftwood",
"--method", "org.gtk.Actions.Activate",
action_name, "[]", "{}"],
timeout=3, capture_output=True,
)
return True
except Exception:
return False
# --- Phase 1: Audit installed view (default) ---
print("\nPhase 1: Auditing Installed view...")
audit_node(app, issues, visited, path_prefix="installed")
print(f" Nodes so far: {len(visited)}")
# --- Phase 2: Switch to Catalog tab ---
if activate_action("show-catalog"):
time.sleep(5) # Wait for catalog to load
print("Phase 2: Auditing Catalog view...")
audit_node(app, issues, visited, path_prefix="catalog")
print(f" Nodes so far: {len(visited)}")
else:
print(" Skipping catalog view (could not activate action)")
# --- Phase 3: Switch to Updates tab ---
if activate_action("show-updates"):
time.sleep(3)
print("Phase 3: Auditing Updates view...")
audit_node(app, issues, visited, path_prefix="updates")
print(f" Nodes so far: {len(visited)}")
else:
print(" Skipping updates view (could not activate action)")
# --- Phase 4: Keyboard focus traversal test ---
if check_level("aaa"):
print("Phase 4: Running keyboard focus traversal test...")
activate_action("show-installed")
time.sleep(1)
focus_issues = run_keyboard_focus_test(app)
issues.extend(focus_issues)
print(f" Focus issues: {len(focus_issues)}")
else:
activate_action("show-installed")
time.sleep(1)
print()
# Sort: errors first, then warnings, then info
severity_order = {"error": 0, "warning": 1, "info": 2}
level_order = {"A": 0, "AA": 1, "AAA": 2}
issues.sort(key=lambda i: (
severity_order.get(i.severity, 3),
level_order.get(i.level, 3),
))
# Print report
errors = [i for i in issues if i.severity == "error"]
warnings = [i for i in issues if i.severity == "warning"]
infos = [i for i in issues if i.severity == "info"]
# Group by WCAG level
a_issues = [i for i in issues if i.level == "A" and i.severity != "info"]
aa_issues = [i for i in issues if i.level == "AA" and i.severity != "info"]
aaa_issues = [i for i in issues if i.level == "AAA" and i.severity != "info"]
print("=" * 70)
print("WCAG 2.2 ACCESSIBILITY AUDIT REPORT")
print(f"Conformance target: {AUDIT_LEVEL.upper()}")
print("=" * 70)
print(f"Total nodes visited: {len(visited)}")
print(f"Issues found: {len(errors)} errors, {len(warnings)} warnings, {len(infos)} info")
print(f" Level A: {len(a_issues)} issues")
print(f" Level AA: {len(aa_issues)} issues")
print(f" Level AAA: {len(aaa_issues)} issues")
print()
# Print stats
print_stats()
if errors:
print(f"--- ERRORS ({len(errors)}) ---")
for issue in errors:
print(issue)
print()
if warnings:
print(f"--- WARNINGS ({len(warnings)}) ---")
for issue in warnings:
print(issue)
print()
if VERBOSE and infos:
print(f"--- INFO ({len(infos)}) ---")
for issue in infos:
print(issue)
print()
if not errors and not warnings:
print("No accessibility issues found!")
# Conformance summary
print()
print("=" * 70)
print("CONFORMANCE SUMMARY")
print("=" * 70)
if not a_issues:
print(" Level A: PASS")
else:
print(f" Level A: FAIL ({len(a_issues)} issues)")
if not aa_issues:
print(" Level AA: PASS")
else:
print(f" Level AA: FAIL ({len(aa_issues)} issues)")
if check_level("aaa"):
if not aaa_issues:
print(" Level AAA: PASS")
else:
print(f" Level AAA: FAIL ({len(aaa_issues)} issues)")
# Note about manual checks required for full AAA
print()
print("NOTE: The following AAA criteria require manual review:")
print(" - SC 1.4.6: Enhanced contrast (7:1 ratio) - use a color contrast tool")
print(" - SC 1.4.7: Low or no background audio")
print(" - SC 1.4.8: Visual presentation (line length, spacing)")
print(" - SC 1.4.9: Images of text (no exceptions)")
print(" - SC 2.2.3: No timing (verify no timed interactions)")
print(" - SC 2.2.4: Interruptions can be postponed")
print(" - SC 2.4.13: Focus appearance (3px outline, area ratio)")
print(" - SC 3.1.3: Unusual words")
print(" - SC 3.1.4: Abbreviations")
print(" - SC 3.1.5: Reading level")
print(" - SC 3.1.6: Pronunciation")
print(" - SC 3.2.5: Change on request")
print(" - SC 3.3.9: Accessible authentication (enhanced)")
print("=" * 70)
# Write JSON report for CI integration
report = {
"audit_level": AUDIT_LEVEL.upper(),
"nodes_visited": len(visited),
"stats": {
"interactive_widgets": stats.total_interactive,
"focusable_widgets": stats.total_focusable,
"images": stats.total_images,
"headings": stats.total_headings,
"links": stats.total_links,
"live_regions": stats.total_live_regions,
"windows_with_titles": stats.windows_with_titles,
"windows_total": stats.windows_total,
},
"summary": {
"errors": len(errors),
"warnings": len(warnings),
"info": len(infos),
"level_a": len(a_issues),
"level_aa": len(aa_issues),
"level_aaa": len(aaa_issues),
},
"conformance": {
"level_a": len(a_issues) == 0,
"level_aa": len(aa_issues) == 0,
"level_aaa": len(aaa_issues) == 0 if check_level("aaa") else None,
},
"issues": [
{
"severity": i.severity,
"criterion": i.criterion,
"level": i.level,
"role": i.role,
"path": i.path,
"message": i.message,
}
for i in issues
],
}
report_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "a11y-report.json",
)
report_path = os.path.normpath(report_path)
try:
with open(report_path, "w") as f:
json.dump(report, f, indent=2)
print(f"\nJSON report written to: {report_path}")
except Exception as e:
print(f"\nCould not write JSON report: {e}")
# Cleanup
if proc:
print("\nTerminating Driftwood...")
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()