#!/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()