From 38723030eb0db58a9d62a45c7725861901309a05 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 20 Jul 2026 17:21:03 -0500 Subject: feat(a11y): add accessibility audit harness (#21 phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the accessibility pass: a regression guard that must exist before any of the remedial phases, so their acceptance criteria are enforced rather than asserted once by hand. - Fix the project-level IPHONEOS_DEPLOYMENT_TARGET, which was 26.2 while all three targets are 17.6. It was shadowed everywhere today, but any target added later would silently inherit it and drop iOS 17.6 support with no error. - Add a DomainDigUITests target running performAccessibilityAudit on the six primary screens, plus a sweep of every root screen at AccessibilityXXXL. Uses the existing DOMAIN_DIG_FORCE_PRO_PLUS debug argument so Pro-gated screens are reachable. - Findings are reported, not failed. The audit surfaces violations that exist today, so gating on them would block unrelated PRs until the whole pass lands. Enforcement is a committed constant, AccessibilityAuditHarness.enforcedAuditTypes, widened per audit type as each phase clears a category. - CI now runs xcodebuild test across two simulators. Audit coverage is not nested between OS versions: on Tracked Domains, iOS 18.6 reported 2 findings and iOS 27.0 reported 6 (including contrast and element-detection issues 18.6 never raised), while at accessibility text sizes the Dashboard produced a hit-region finding on 18.6 that 27.0 did not. - Simulator selection is now dynamic and floor-aware. The previous selector took the first iPhone from any runtime, which can resolve to a simulator below the deployment target where the app cannot install. Baseline on iOS 18.6: 15 findings across 7 tests — text clipping on every screen, contrast on Inspect and Settings, and a hit-region failure on the Dashboard at accessibility text sizes. --- DomainDigUITests/AccessibilityAuditHarness.swift | 121 +++++++++++++++++++++++ DomainDigUITests/AccessibilityAuditTests.swift | 84 ++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 DomainDigUITests/AccessibilityAuditHarness.swift create mode 100644 DomainDigUITests/AccessibilityAuditTests.swift (limited to 'DomainDigUITests') diff --git a/DomainDigUITests/AccessibilityAuditHarness.swift b/DomainDigUITests/AccessibilityAuditHarness.swift new file mode 100644 index 0000000..80257e1 --- /dev/null +++ b/DomainDigUITests/AccessibilityAuditHarness.swift @@ -0,0 +1,121 @@ +import XCTest + +/// Shared plumbing for the accessibility audit suite. +/// +/// `performAccessibilityAudit` checks contrast, hit-region size, clipped text at +/// large Dynamic Type, element descriptions, and trait correctness — the same +/// categories the accessibility pass in issue #21 works through. +/// +/// **The suite reports by default and fails only for enforced categories.** The +/// audit surfaces violations that exist today, so failing on everything would +/// block unrelated PRs until the whole pass lands. `enforcedAuditTypes` below is +/// the ratchet: widen it as each phase of #21 clears a category. +/// +/// Two alternatives were tried and rejected: +/// +/// - *A per-screen baseline count.* Audit coverage is not nested across OS +/// versions — the same screen legitimately yields different counts on the +/// floor simulator and the current one, so no single committed number is +/// correct for both. +/// - *An environment variable.* Neither a plain `xcodebuild` env var nor a +/// `TEST_RUNNER_`-prefixed build setting reaches this process, so the toggle +/// silently did nothing. A committed constant also makes "when did contrast +/// become enforced?" answerable with `git blame` instead of CI tribal +/// knowledge. +@MainActor +enum AccessibilityAuditHarness { + /// Launch argument that lifts feature gating so Pro-only screens are + /// reachable. `PurchaseService` honours this in `DEBUG` builds only. + private static let forceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS" + + /// Audit categories that fail the build. Everything else is reported only. + /// + /// Empty until the accessibility pass starts landing. Suggested ratchet, + /// following the phases in issue #21: + /// + /// - after phase 2 (semantic colors + light mode): `.contrast` + /// - after phase 3 (Dynamic Type + reflow): `.textClipped`, `.dynamicType`, + /// `.hitRegion` + /// - after phase 4 (VoiceOver): `.elementDetection`, + /// `.sufficientElementDescription`, `.trait` + static let enforcedAuditTypes: XCUIAccessibilityAuditType = [] + + /// Launches the app with feature gating lifted, optionally at a specific + /// content size category. + static func launch(contentSizeCategory: String? = nil) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [forceProPlusArgument] + if let contentSizeCategory { + app.launchArguments += ["-UIPreferredContentSizeCategoryName", contentSizeCategory] + } + app.launch() + return app + } + + /// Runs a full audit and records every finding against the test. + /// + /// Findings are logged and attached to the result bundle so a CI run + /// produces the burndown list as an artifact rather than only a pass/fail. + static func audit( + _ app: XCUIApplication, + screen: String, + test: XCTestCase + ) throws { + var findings: [String] = [] + + try app.performAccessibilityAudit { issue in + let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty + let marker = isEnforced ? "FAIL" : "report" + findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription)") + // true suppresses the finding, false reports it as a test failure. + return !isEnforced + } + + let summary = findings.isEmpty + ? "\(screen): no accessibility findings" + : "\(screen): \(findings.count) finding(s)\n" + findings.sorted().map { " • \($0)" }.joined(separator: "\n") + + print(summary) + + let attachment = XCTAttachment(string: summary) + attachment.name = "a11y-audit-\(screen)" + attachment.lifetime = .keepAlways + test.add(attachment) + } + + /// `XCUIAccessibilityAuditType` is an option set whose description is just a + /// raw bitmask, which makes the burndown list unreadable. Resolve it against + /// the named members rather than hard-coding bit positions, so this keeps + /// working if Apple adds audit types. + private static func name(for type: XCUIAccessibilityAuditType) -> String { + let known: [(XCUIAccessibilityAuditType, String)] = [ + (.contrast, "contrast"), + (.elementDetection, "elementDetection"), + (.hitRegion, "hitRegion"), + (.sufficientElementDescription, "sufficientElementDescription"), + (.dynamicType, "dynamicType"), + (.textClipped, "textClipped"), + (.trait, "trait") + ] + let matched = known.filter { type.contains($0.0) }.map(\.1) + return matched.isEmpty ? "unknown(\(type.rawValue))" : matched.joined(separator: "+") + } +} + +extension XCUIApplication { + /// Taps a root tab by its visible label. + /// + /// Falls back to a plain button query because the tab bar is only present in + /// the compact size class — in regular width `RootTabView` renders a + /// `NavigationSplitView` sidebar instead. + @MainActor + func selectRootTab(_ name: String) { + let tabButton = tabBars.buttons[name] + let element = tabButton.waitForExistence(timeout: 5) ? tabButton : buttons[name] + XCTAssertTrue( + element.waitForExistence(timeout: 5), + "Could not find a way to reach the \(name) screen" + ) + element.tap() + } +} diff --git a/DomainDigUITests/AccessibilityAuditTests.swift b/DomainDigUITests/AccessibilityAuditTests.swift new file mode 100644 index 0000000..63b3d02 --- /dev/null +++ b/DomainDigUITests/AccessibilityAuditTests.swift @@ -0,0 +1,84 @@ +import XCTest + +/// One accessibility audit per primary screen, plus a Dynamic Type sweep. +/// +/// See `AccessibilityAuditHarness` for why these report rather than fail by +/// default, and how to make them enforcing. +@MainActor +final class AccessibilityAuditTests: XCTestCase { + override func setUp() { + // Keep going after a failure so an enforced audit still collects and + // attaches every finding. With this off, XCTest aborts at the first + // reported issue and the burndown list is lost precisely when a category + // is being enforced. + continueAfterFailure = true + } + + // MARK: Per-screen audits + + func testInspectScreen() throws { + let app = AccessibilityAuditHarness.launch() + app.selectRootTab("Inspect") + try AccessibilityAuditHarness.audit(app, screen: "inspect", test: self) + } + + func testDashboardScreen() throws { + let app = AccessibilityAuditHarness.launch() + app.selectRootTab("Dashboard") + try AccessibilityAuditHarness.audit(app, screen: "dashboard", test: self) + } + + func testAuditScreen() throws { + let app = AccessibilityAuditHarness.launch() + app.selectRootTab("Audit") + try AccessibilityAuditHarness.audit(app, screen: "audit", test: self) + } + + func testHistoryScreen() throws { + let app = AccessibilityAuditHarness.launch() + app.selectRootTab("History") + try AccessibilityAuditHarness.audit(app, screen: "history", test: self) + } + + func testSettingsScreen() throws { + let app = AccessibilityAuditHarness.launch() + app.selectRootTab("Settings") + try AccessibilityAuditHarness.audit(app, screen: "settings", test: self) + } + + func testTrackedDomainsScreen() throws { + let app = AccessibilityAuditHarness.launch() + app.selectRootTab("Settings") + + let trackedDomains = app.buttons["Tracked Domains"] + XCTAssertTrue( + trackedDomains.waitForExistence(timeout: 5), + "Settings no longer offers a Tracked Domains row" + ) + trackedDomains.tap() + + try AccessibilityAuditHarness.audit(app, screen: "tracked-domains", test: self) + } + + // MARK: Dynamic Type + + /// Re-audits every root screen at the largest accessibility content size. + /// + /// This is where clipped text and fixed-height containers surface — the + /// `.accessibility5`-class failures that the fixed geometry in + /// `AppDensityMetrics` is expected to produce until phase 3 of #21 lands. + func testAllScreensAtLargestAccessibilitySize() throws { + let app = AccessibilityAuditHarness.launch( + contentSizeCategory: "UICTContentSizeCategoryAccessibilityXXXL" + ) + + for tab in ["Inspect", "Dashboard", "Audit", "History", "Settings"] { + app.selectRootTab(tab) + try AccessibilityAuditHarness.audit( + app, + screen: "\(tab.lowercased())-accessibilityXXXL", + test: self + ) + } + } +} -- cgit v1.2.3