diff options
Diffstat (limited to 'DomainDigUITests')
| -rw-r--r-- | DomainDigUITests/AccessibilityAuditHarness.swift | 121 | ||||
| -rw-r--r-- | DomainDigUITests/AccessibilityAuditTests.swift | 84 |
2 files changed, 205 insertions, 0 deletions
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 + ) + } + } +} |
