krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.9.0: DomainDigUITests/AccessibilityAuditHarness.swift · raw

  1import XCTest
  2
  3/// Shared plumbing for the accessibility audit suite.
  4///
  5/// `performAccessibilityAudit` checks contrast, hit-region size, clipped text at
  6/// large Dynamic Type, element descriptions, and trait correctness  the same
  7/// categories the accessibility pass in issue #21 works through.
  8///
  9/// **The suite reports by default and fails only for enforced categories.** The
 10/// audit surfaces violations that exist today, so failing on everything would
 11/// block unrelated PRs until the whole pass lands. `enforcedAuditTypes` below is
 12/// the ratchet: widen it as each phase of #21 clears a category.
 13///
 14/// Two alternatives were tried and rejected:
 15///
 16/// - *A per-screen baseline count.* Audit coverage is not nested across OS
 17///   versions  the same screen legitimately yields different counts on the
 18///   floor simulator and the current one, so no single committed number is
 19///   correct for both.
 20/// - *An environment variable.* Neither a plain `xcodebuild` env var nor a
 21///   `TEST_RUNNER_`-prefixed build setting reaches this process, so the toggle
 22///   silently did nothing. A committed constant also makes "when did contrast
 23///   become enforced?" answerable with `git blame` instead of CI tribal
 24///   knowledge.
 25@MainActor
 26enum AccessibilityAuditHarness {
 27    /// Launch argument that lifts feature gating so Pro-only screens are
 28    /// reachable. `PurchaseService` honours this in `DEBUG` builds only.
 29    private static let forceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS"
 30
 31    /// Audit categories that fail the build on the empty-state suite. A named
 32    /// finding in any of these is a regression in the phase 15 work.
 33    ///
 34    /// `.contrast` is deliberately absent: the two long-standing Settings
 35    /// findings come from rows scrolled under the translucent tab bar, and their
 36    /// attribution flips between a row name and nil run-to-run, so there is no
 37    /// suppression narrow enough to keep CI stable. Contrast stays report-only,
 38    /// with the palette centralised in `Shared/Colors.xcassets` as the actual
 39    /// guard.
 40    static let enforcedAuditTypes: XCUIAccessibilityAuditType = [
 41        .textClipped,
 42        .dynamicType,
 43        .hitRegion,
 44        .elementDetection,
 45        .sufficientElementDescription,
 46        .trait
 47    ]
 48
 49    /// How many times to retry an audit that misses its internal deadline.
 50    private static let auditAttempts = 3
 51
 52    /// Launch argument that seeds deterministic in-memory tracked domains and
 53    /// batch results (DEBUG builds only; never persisted). Without it the dense
 54    /// rows and portfolio sections render nothing, which is how five phases of
 55    /// row treatment went unmeasured.
 56    private static let seedFixturesArgument = "DOMAIN_DIG_SEED_FIXTURES"
 57
 58    /// Launches the app with feature gating lifted, optionally at a specific
 59    /// content size category and with the audit fixtures seeded.
 60    static func launch(contentSizeCategory: String? = nil, seeded: Bool = false) -> XCUIApplication {
 61        let app = XCUIApplication()
 62        app.launchArguments = [forceProPlusArgument]
 63        if seeded {
 64            app.launchArguments.append(seedFixturesArgument)
 65        }
 66        if let contentSizeCategory {
 67            app.launchArguments += ["-UIPreferredContentSizeCategoryName", contentSizeCategory]
 68        }
 69        app.launch()
 70        return app
 71    }
 72
 73    /// Runs a full audit and records every finding against the test.
 74    ///
 75    /// Findings are logged and attached to the result bundle so a CI run
 76    /// produces the burndown list as an artifact rather than only a pass/fail.
 77    ///
 78    /// Returns `false` if the audit could not complete, leaving the screen
 79    /// unaudited. Callers turn that into an `XCTSkip`  reporting a pass would
 80    /// claim coverage that did not happen.
 81    /// `reportOnly` disables enforcement for this call. Used by the seeded
 82    /// tests: bisection showed the audit degrades on `children: .ignore`
 83    /// content  the correct VoiceOver treatment for dense rows  reporting
 84    /// unattributed contrast/dynamicType failures on rows that measure 67:1
 85    /// and render correctly. Until that behaves, the seeded screens report
 86    /// their burndown without gating CI.
 87    @discardableResult
 88    static func audit(
 89        _ app: XCUIApplication,
 90        screen: String,
 91        test: XCTestCase,
 92        reportOnly: Bool = false
 93    ) throws -> Bool {
 94        var findings: [String] = []
 95        var timeout: Error?
 96
 97        // The audit traverses the whole element tree and has its own internal
 98        // deadline, which slower CI runners miss on the denser screens. That is a
 99        // tooling timeout, not an app defect, so retry before giving up.
100        //
101        // Only the timeout is retried. If a category is enforced and the audit
102        // reports findings before timing out, those failures are already recorded
103        // and a retry would duplicate them  accepted, because the alternative is
104        // losing the run to an infrastructure hiccup.
105        for attempt in 1...auditAttempts {
106            findings.removeAll()
107            timeout = nil
108            do {
109                try app.performAccessibilityAudit { issue in
110                    // Include the element so the burndown says *what* to fix, not
111                    // just that something is wrong.
112                    let element = issue.element.map { el -> String in
113                        let label = el.label.isEmpty ? el.identifier : el.label
114                        return label.isEmpty ? "\(el.elementType)" : "\"\(label)\""
115                    } ?? "unknown element"
116
117                    // Characterised noise never fails, but is still logged with
118                    // its reason  nothing disappears silently.
119                    if let noise = noiseReason(for: issue) {
120                        findings.append("[noise: \(noise)][\(name(for: issue.auditType))] \(issue.compactDescription)\(element)")
121                        return true
122                    }
123
124                    let isEnforced = !reportOnly && !enforcedAuditTypes.intersection(issue.auditType).isEmpty
125                    let marker = isEnforced ? "FAIL" : "report"
126                    findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription)\(element)")
127                    // true suppresses the finding, false reports it as a test failure.
128                    return !isEnforced
129                }
130                break
131            } catch let error as NSError where error.isAccessibilityAuditTimeout {
132                timeout = error
133                print("\(screen): audit timed out (attempt \(attempt) of \(auditAttempts))")
134            }
135        }
136
137        if timeout != nil {
138            let message = "\(screen): audit did not complete in time after \(auditAttempts) attempts — screen NOT audited"
139            print(message)
140            let attachment = XCTAttachment(string: message)
141            attachment.name = "a11y-audit-\(screen)-timeout"
142            attachment.lifetime = .keepAlways
143            test.add(attachment)
144            return false
145        }
146
147        let summary = findings.isEmpty
148            ? "\(screen): no accessibility findings"
149            : "\(screen): \(findings.count) finding(s)\n" + findings.sorted().map { "\($0)" }.joined(separator: "\n")
150
151        print(summary)
152
153        let attachment = XCTAttachment(string: summary)
154        attachment.name = "a11y-audit-\(screen)"
155        attachment.lifetime = .keepAlways
156        test.add(attachment)
157
158        return true
159    }
160
161    /// Classifies findings that are measurement artifacts, not app defects.
162    /// Each rule exists because it was proven, not assumed; the evidence is
163    /// recorded inline. A classified finding is logged with its reason and
164    /// never fails the build.
165    private static func noiseReason(for issue: XCUIAccessibilityAuditIssue) -> String? {
166        // WCAG 1.4.3 exempts inactive components from contrast requirements,
167        // but the audit flags them anyway. Proven on Inspect's Run button,
168        // disabled until a domain is typed. (Driving the UI to enable it was
169        // worse: the raised keyboard followed the audit onto later screens and
170        // flagged the emoji picker.)
171        if issue.auditType.contains(.contrast), issue.element?.isEnabled == false {
172            return "disabled control, WCAG 1.4.3 exempt"
173        }
174
175        // "Nearly passed" is the audit's near-miss band, not a failure. The
176        // only occurrences are iOS-rendered Settings section headers, whose
177        // styling is the system's.
178        if issue.compactDescription.localizedCaseInsensitiveContains("nearly passed") {
179            return "near-miss, not a failure"
180        }
181
182        // Placeholder text in text/search fields is reported clipped at ANY
183        // length  shortening "Search portfolio" to "Search" changed nothing 
184        // and the search field's hit region at accessibility sizes is the
185        // system's own control. Reading `elementType` here is safe; reading
186        // `frame` is not (it kills element attribution for the whole audit).
187        if let type = issue.element?.elementType, type == .searchField || type == .textField {
188            if issue.auditType.contains(.textClipped) || issue.auditType.contains(.hitRegion) {
189                return "system field placeholder/hit region, length-independent"
190            }
191        }
192
193        // Unattributed clipped-text/dynamic-type findings. Bisection showed the
194        // audit loses attribution inside NavigationLink rows and
195        // children-ignored elements and then reports failures on content that
196        // is visually verified correct (and, for the one long-standing
197        // empty-watchlist phantom, renders nothing clipped at all). Named
198        // findings in these categories still enforce.
199        if issue.element == nil,
200           issue.auditType.contains(.textClipped) || issue.auditType.contains(.dynamicType) {
201            return "unattributed, audit artifact on ignored/link content"
202        }
203
204        return nil
205    }
206
207    /// `XCUIAccessibilityAuditType` is an option set whose description is just a
208    /// raw bitmask, which makes the burndown list unreadable. Resolve it against
209    /// the named members rather than hard-coding bit positions, so this keeps
210    /// working if Apple adds audit types.
211    private static func name(for type: XCUIAccessibilityAuditType) -> String {
212        let known: [(XCUIAccessibilityAuditType, String)] = [
213            (.contrast, "contrast"),
214            (.elementDetection, "elementDetection"),
215            (.hitRegion, "hitRegion"),
216            (.sufficientElementDescription, "sufficientElementDescription"),
217            (.dynamicType, "dynamicType"),
218            (.textClipped, "textClipped"),
219            (.trait, "trait")
220        ]
221        let matched = known.filter { type.contains($0.0) }.map(\.1)
222        return matched.isEmpty ? "unknown(\(type.rawValue))" : matched.joined(separator: "+")
223    }
224}
225
226private extension NSError {
227    /// `Audit failed to complete in time`  the audit's own deadline, raised by
228    /// XCTest rather than by anything wrong with the app.
229    var isAccessibilityAuditTimeout: Bool {
230        domain == "com.apple.xcode.xctest.accessibilityAudit" && code == -56
231    }
232}
233
234extension XCUIApplication {
235    /// Taps a root tab by its visible label.
236    ///
237    /// Falls back to a plain button query because the tab bar is only present in
238    /// the compact size class  in regular width `RootTabView` renders a
239    /// `NavigationSplitView` sidebar instead.
240    @MainActor
241    func selectRootTab(_ name: String) {
242        let tabButton = tabBars.buttons[name]
243        let element = tabButton.waitForExistence(timeout: 5) ? tabButton : buttons[name]
244        XCTAssertTrue(
245            element.waitForExistence(timeout: 5),
246            "Could not find a way to reach the \(name) screen"
247        )
248        element.tap()
249    }
250}