summaryrefslogtreecommitdiff
path: root/DomainDigUITests/AccessibilityAuditHarness.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-20 17:21:03 -0500
committerChristian Cleberg <[email protected]>2026-07-20 17:55:24 -0500
commit38723030eb0db58a9d62a45c7725861901309a05 (patch)
tree808f81c5ff6bbf843dbca6f55fb5a949cc9e41b8 /DomainDigUITests/AccessibilityAuditHarness.swift
parent23ef8ef9e632b205675b0fda8200635201d2a889 (diff)
downloaddomain-dig-38723030eb0db58a9d62a45c7725861901309a05.tar.gz
domain-dig-38723030eb0db58a9d62a45c7725861901309a05.tar.bz2
domain-dig-38723030eb0db58a9d62a45c7725861901309a05.zip
feat(a11y): add accessibility audit harness (#21 phase 0)
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.
Diffstat (limited to 'DomainDigUITests/AccessibilityAuditHarness.swift')
-rw-r--r--DomainDigUITests/AccessibilityAuditHarness.swift121
1 files changed, 121 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()
+ }
+}