1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
|
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 on the empty-state suite. A named
/// finding in any of these is a regression in the phase 1–5 work.
///
/// `.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
/// Launch argument that seeds deterministic in-memory tracked domains and
/// batch results (DEBUG builds only; never persisted). Without it the dense
/// rows and portfolio sections render nothing, which is how five phases of
/// row treatment went unmeasured.
private static let seedFixturesArgument = "DOMAIN_DIG_SEED_FIXTURES"
/// Launches the app with feature gating lifted, optionally at a specific
/// content size category and with the audit fixtures seeded.
static func launch(contentSizeCategory: String? = nil, seeded: Bool = false) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = [forceProPlusArgument]
if seeded {
app.launchArguments.append(seedFixturesArgument)
}
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.
///
/// 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,
reportOnly: Bool = false
) throws -> Bool {
var findings: [String] = []
var timeout: Error?
// The audit traverses the whole element tree and has its own internal
// deadline, which slower CI runners miss on the denser screens. That is a
// tooling timeout, not an app defect, so retry before giving up.
//
// Only the timeout is retried. If a category is enforced and the audit
// reports findings before timing out, those failures are already recorded
// and a retry would duplicate them — accepted, because the alternative is
// losing the run to an infrastructure hiccup.
for attempt in 1...auditAttempts {
findings.removeAll()
timeout = nil
do {
try app.performAccessibilityAudit { issue in
// 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
}
break
} catch let error as NSError where error.isAccessibilityAuditTimeout {
timeout = error
print("\(screen): audit timed out (attempt \(attempt) of \(auditAttempts))")
}
}
if timeout != nil {
let message = "\(screen): audit did not complete in time after \(auditAttempts) attempts — screen NOT audited"
print(message)
let attachment = XCTAttachment(string: message)
attachment.name = "a11y-audit-\(screen)-timeout"
attachment.lifetime = .keepAlways
test.add(attachment)
return false
}
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)
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"
}
// iOS-27-only Settings `Section` header dynamicType finding. On iOS 27.0
// (and only there) the audit reports "font sizes partially unsupported"
// against a Settings section header — the same system-rendered headers
// already carved out for the contrast near-miss above. Proof it is a
// system-chrome artifact, not an app defect:
// • Each is a plain `Section("Services")` etc. (ContentView.swift ~2768);
// the app sets no font, so the scaling is UIKit's `.footnote` header.
// • Version-specific: absent on the iOS 18.6 floor and on the 26.x
// runtime CI runs (both audit clean); it surfaces only under 27.0.
// ACCESSIBILITY.md's coverage table records the same asymmetry
// ("dynamicType finding 18.6 missed").
// • Attribution is unstable run-to-run across the header set
// (Tier/Preferences/Services), exactly like the documented contrast
// flip — so it lands on whichever header the traversal reaches first.
// Overriding every Section header with a custom scaling `Text` to chase
// this was rejected for the contrast case (ACCESSIBILITY.md) for trading
// platform convention for nothing; the same holds here. Scoped to
// dynamicType on the exact Settings header titles so a real regression
// on app-controlled text still enforces.
if issue.auditType.contains(.dynamicType),
let label = issue.element?.label,
settingsSectionHeaders.contains(label) {
return "iOS-rendered Settings section header, app sets no font (27.0-only)"
}
return nil
}
/// The Settings screen's `Section(_:)` header titles. UIKit renders these;
/// the app passes only a string literal. Used to scope the section-header
/// dynamicType carve-out narrowly (see `noiseReason`).
private static let settingsSectionHeaders: Set<String> = [
"Tier", "Preferences", "Services", "Data", "About"
]
/// `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: "+")
}
}
private extension NSError {
/// `Audit failed to complete in time` — the audit's own deadline, raised by
/// XCTest rather than by anything wrong with the app.
var isAccessibilityAuditTimeout: Bool {
domain == "com.apple.xcode.xctest.accessibilityAudit" && code == -56
}
}
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()
}
}
|