From dd41ed2c51beae07288fdfa724e410e0d321d278 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 22 Jul 2026 01:01:05 -0500 Subject: feat(a11y): engage the audit enforcement ratchet (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The point of the Phase 0 harness finally lands: named findings in textClipped, dynamicType, hitRegion, elementDetection, sufficientElementDescription, and trait now FAIL the build on the empty-state suite. Regressions in five phases of accessibility work are gated, not narrated. Three carve-outs, each earned by evidence rather than convenience: - contrast stays report-only. The two long-standing Settings findings are rows scrolled under the translucent tab bar, and their attribution flips between a row name and nil run-to-run — no suppression is narrow enough to keep CI stable. The centralised palette is the real guard. - The seeded dense-row tests run reportOnly. Bisection showed the audit degrades on children-ignored content — the correct VoiceOver treatment for dense rows — emitting unattributed contrast/dynamicType failures on rows that measure 6-7:1 and render correctly. - Characterised noise is suppressed narrowly and always logged as [noise: reason]: disabled controls (WCAG 1.4.3 exempt), "nearly passed" near-misses, system field placeholders (flagged at any length — proven by shortening them to no effect), and unattributed clipped/dynamic-type artifacts. noiseReason(for:) records each rule's provenance inline. Validated in both directions. Positive: the full 11-test suite passes with enforcement live, dark and light, on an erased simulator. Negative: re-injecting the Phase 3 icon-exposure regression produced two named [FAIL] findings and a failed suite — on both screens sharing the component — then went green again on revert. Docs and the CI workflow comment updated to describe the engaged state. --- DomainDigUITests/AccessibilityAuditHarness.swift | 105 +++++++++++++++++------ 1 file changed, 80 insertions(+), 25 deletions(-) (limited to 'DomainDigUITests/AccessibilityAuditHarness.swift') diff --git a/DomainDigUITests/AccessibilityAuditHarness.swift b/DomainDigUITests/AccessibilityAuditHarness.swift index e025dcd..64d0993 100644 --- a/DomainDigUITests/AccessibilityAuditHarness.swift +++ b/DomainDigUITests/AccessibilityAuditHarness.swift @@ -28,17 +28,23 @@ enum AccessibilityAuditHarness { /// 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. + /// Audit categories that fail the build on the empty-state suite. A named + /// finding in any of these is a regression in the phase 1–5 work. /// - /// 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 = [] + /// `.contrast` is deliberately absent: the two long-standing Settings + /// findings come from rows scrolled under the translucent tab bar, and their + /// attribution flips between a row name and nil run-to-run, so there is no + /// suppression narrow enough to keep CI stable. Contrast stays report-only, + /// with the palette centralised in `Shared/Colors.xcassets` as the actual + /// guard. + static let enforcedAuditTypes: XCUIAccessibilityAuditType = [ + .textClipped, + .dynamicType, + .hitRegion, + .elementDetection, + .sufficientElementDescription, + .trait + ] /// How many times to retry an audit that misses its internal deadline. private static let auditAttempts = 3 @@ -72,11 +78,18 @@ enum AccessibilityAuditHarness { /// Returns `false` if the audit could not complete, leaving the screen /// unaudited. Callers turn that into an `XCTSkip` — reporting a pass would /// claim coverage that did not happen. + /// `reportOnly` disables enforcement for this call. Used by the seeded + /// tests: bisection showed the audit degrades on `children: .ignore` + /// content — the correct VoiceOver treatment for dense rows — reporting + /// unattributed contrast/dynamicType failures on rows that measure 6–7:1 + /// and render correctly. Until that behaves, the seeded screens report + /// their burndown without gating CI. @discardableResult static func audit( _ app: XCUIApplication, screen: String, - test: XCTestCase + test: XCTestCase, + reportOnly: Bool = false ) throws -> Bool { var findings: [String] = [] var timeout: Error? @@ -94,26 +107,22 @@ enum AccessibilityAuditHarness { timeout = nil do { try app.performAccessibilityAudit { issue in - // WCAG 1.4.3 exempts inactive components from contrast - // requirements, but the audit flags them anyway. Inspect's - // Run button is disabled until a domain is typed, so the - // empty state reported a contrast failure that was never a - // real defect. Suppressing on the rule beats driving the UI - // to enable the control: typing raises the keyboard, which - // then follows the audit onto later screens and flags the - // system emoji picker's category buttons. - if issue.auditType.contains(.contrast), issue.element?.isEnabled == false { - return true - } - - let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty - let marker = isEnforced ? "FAIL" : "report" // Include the element so the burndown says *what* to fix, not // just that something is wrong. let element = issue.element.map { el -> String in let label = el.label.isEmpty ? el.identifier : el.label return label.isEmpty ? "\(el.elementType)" : "\"\(label)\"" } ?? "unknown element" + + // Characterised noise never fails, but is still logged with + // its reason — nothing disappears silently. + if let noise = noiseReason(for: issue) { + findings.append("[noise: \(noise)][\(name(for: issue.auditType))] \(issue.compactDescription) — \(element)") + return true + } + + let isEnforced = !reportOnly && !enforcedAuditTypes.intersection(issue.auditType).isEmpty + let marker = isEnforced ? "FAIL" : "report" findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription) — \(element)") // true suppresses the finding, false reports it as a test failure. return !isEnforced @@ -149,6 +158,52 @@ enum AccessibilityAuditHarness { return true } + /// Classifies findings that are measurement artifacts, not app defects. + /// Each rule exists because it was proven, not assumed; the evidence is + /// recorded inline. A classified finding is logged with its reason and + /// never fails the build. + private static func noiseReason(for issue: XCUIAccessibilityAuditIssue) -> String? { + // WCAG 1.4.3 exempts inactive components from contrast requirements, + // but the audit flags them anyway. Proven on Inspect's Run button, + // disabled until a domain is typed. (Driving the UI to enable it was + // worse: the raised keyboard followed the audit onto later screens and + // flagged the emoji picker.) + if issue.auditType.contains(.contrast), issue.element?.isEnabled == false { + return "disabled control, WCAG 1.4.3 exempt" + } + + // "Nearly passed" is the audit's near-miss band, not a failure. The + // only occurrences are iOS-rendered Settings section headers, whose + // styling is the system's. + if issue.compactDescription.localizedCaseInsensitiveContains("nearly passed") { + return "near-miss, not a failure" + } + + // Placeholder text in text/search fields is reported clipped at ANY + // length — shortening "Search portfolio" to "Search" changed nothing — + // and the search field's hit region at accessibility sizes is the + // system's own control. Reading `elementType` here is safe; reading + // `frame` is not (it kills element attribution for the whole audit). + if let type = issue.element?.elementType, type == .searchField || type == .textField { + if issue.auditType.contains(.textClipped) || issue.auditType.contains(.hitRegion) { + return "system field placeholder/hit region, length-independent" + } + } + + // Unattributed clipped-text/dynamic-type findings. Bisection showed the + // audit loses attribution inside NavigationLink rows and + // children-ignored elements and then reports failures on content that + // is visually verified correct (and, for the one long-standing + // empty-watchlist phantom, renders nothing clipped at all). Named + // findings in these categories still enforce. + if issue.element == nil, + issue.auditType.contains(.textClipped) || issue.auditType.contains(.dynamicType) { + return "unattributed, audit artifact on ignored/link content" + } + + return nil + } + /// `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 -- cgit v1.2.3