Commit 38723030eb

38723030eb0db58a9d62a45c7725861901309a05

parent: 23ef8ef9e6

Unsigned

cmc <hello@cleberg.net> · 2026-07-20 22:21 UTC

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.

Layout: unified · split

.github/workflows/build.yml +80 −22
@@ -1,13 +1,37 @@
11name: Build
22
3# Compile gate for the GitHub mirror. builds.sr.ht is the primary remote for
4# this project but has no macOS images, so xcodebuild cannot run there; this job
5# compiles the app on a GitHub-hosted macOS runner instead.
3# Compile gate and accessibility audit for the GitHub mirror. builds.sr.ht is the
4# primary remote for this project but has no macOS images, so xcodebuild cannot
5# run there; this job builds and tests on a GitHub-hosted macOS runner instead.
66#
7# This runs `xcodebuild build`, not `test`: the project has no test target yet
8# (planned for v5.0.0 in RELEASE_ROADMAP.md). When a test target and test plan
9# exist, switch the final step to `xcodebuild test -testPlan <name>` and rename
10# this workflow — the rest of the setup already matches a test run.
7# This runs `xcodebuild test`, which also compiles the app, the widget, and the
8# share extension (the DomainDig scheme's build action pulls both in as
9# dependencies). The test target is DomainDigUITests — an accessibility audit
10# suite; see DomainDigUITests/AccessibilityAuditHarness.swift.
11#
12# The audit REPORTS but does not FAIL by default. It surfaces violations that
13# exist today, so gating on it would block every unrelated PR until the
14# accessibility pass in issue #21 completes. Findings land in the job log and in
15# the uploaded .xcresult bundle, tagged [report] or [FAIL].
16#
17# Enforcement is a committed constant, not a CI setting: widen
18# `AccessibilityAuditHarness.enforcedAuditTypes` as each phase clears a category.
19# (Env vars were tried first — neither a plain xcodebuild env var nor a
20# TEST_RUNNER_-prefixed build setting reaches the UI test process.)
21#
22# The matrix runs two simulators because audit coverage is NOT nested — each
23# runtime reports findings the other misses, in both directions. Measured on the
24# Tracked Domains screen, iOS 18.6 reported 2 issues and iOS 27.0 reported 6
25# (including contrast and element-detection issues 18.6 never raised); at
26# accessibility text sizes the Dashboard produced a hit-region finding on 18.6
27# that 27.0 did not. Testing only the newest image would leave the oldest
28# supported OS unchecked; testing only the floor would miss newer audit checks.
29#
30# Runtimes are resolved dynamically rather than pinned: the deployment target is
31# 17.6, but no 17.6 simulator runtime ships, so "floor" means the oldest
32# available runtime at or above the deployment target (18.6 at time of writing).
33# The previous selector took the first iPhone from any runtime, which could pick
34# a simulator BELOW the deployment target, where the app cannot install.
1135#
1236# pull_request only, plus manual dispatch. GitHub builds the merge result (PR
1337# merged into main), so a green PR validates exactly what will land on main.
@@ -31,13 +55,25 @@ concurrency:
3155 group: build-${{ github.ref }}
3256 cancel-in-progress: true
3357
58env:
59 # Keep in sync with IPHONEOS_DEPLOYMENT_TARGET in DomainDig.xcodeproj.
60 DEPLOYMENT_TARGET_MAJOR: '17'
61 DEPLOYMENT_TARGET_MINOR: '6'
62
3463jobs:
35 build:
36 name: xcodebuild build
37 # macos-latest still points at macOS 15, which lacks the iOS 26 SDK this app
38 # targets (deployment target 26.2).
64 test:
65 name: xcodebuild test (${{ matrix.tier }})
66 # macos-latest still points at macOS 15, which lacks the iOS 26+ SDK this app
67 # is built against.
3968 runs-on: macos-26
4069
70 strategy:
71 fail-fast: false
72 matrix:
73 # floor = oldest runtime the app actually supports
74 # current = newest runtime available on the image
75 tier: [floor, current]
76
4177 steps:
4278 - uses: actions/checkout@v7
4379
@@ -50,29 +86,51 @@ jobs:
5086 id: sim
5187 run: |
5288 set -euo pipefail
53 udid=$(xcrun simctl list devices available --json \
54 | jq -r '[.devices[][] | select(.name | startswith("iPhone"))] | first | .udid')
55 if [ -z "$udid" ] || [ "$udid" = "null" ]; then
56 echo "No available iPhone simulator on this image" >&2
89 floor=$(( DEPLOYMENT_TARGET_MAJOR * 1000 + DEPLOYMENT_TARGET_MINOR ))
90
91 selected=$(xcrun simctl list devices available --json \
92 | jq -c --argjson floor "$floor" --arg tier "${{ matrix.tier }}" '
93 [ .devices | to_entries[]
94 | (.key | capture("SimRuntime\\.iOS-(?<maj>[0-9]+)-(?<min>[0-9]+)$")) as $v
95 | (($v.maj | tonumber) * 1000 + ($v.min | tonumber)) as $rank
96 | select($rank >= $floor)
97 | .value[]
98 | select(.name | startswith("iPhone"))
99 | { rank: $rank, udid: .udid, name: .name, os: "\($v.maj).\($v.min)" }
100 ]
101 | sort_by(.rank, .name)
102 | if length == 0 then empty
103 elif $tier == "floor" then .[0]
104 else .[-1] end
105 ')
106
107 if [ -z "$selected" ]; then
108 echo "::error::No iPhone simulator at or above iOS ${DEPLOYMENT_TARGET_MAJOR}.${DEPLOYMENT_TARGET_MINOR} on this image"
57109 xcrun simctl list devices available >&2
58110 exit 1
59111 fi
60 echo "udid=$udid" >> "$GITHUB_OUTPUT"
61112
62 - name: Build
113 label=$(echo "$selected" | jq -r '"\(.name) (iOS \(.os))"')
114 echo "Selected $label"
115 echo "udid=$(echo "$selected" | jq -r .udid)" >> "$GITHUB_OUTPUT"
116 echo "label=$label" >> "$GITHUB_OUTPUT"
117
118 - name: Test on ${{ steps.sim.outputs.label }}
63119 run: |
64120 set -o pipefail
65 xcodebuild build \
121 xcodebuild test \
66122 -project DomainDig.xcodeproj \
67123 -scheme DomainDig \
68124 -destination "id=${{ steps.sim.outputs.udid }}" \
69 -resultBundlePath BuildResults.xcresult \
125 -resultBundlePath TestResults.xcresult \
70126 CODE_SIGNING_ALLOWED=NO
71127
72128 - name: Upload results
73 if: failure()
129 # Always upload: on success the bundle carries the accessibility burndown
130 # list, which is the reason this suite exists.
131 if: always()
74132 uses: actions/upload-artifact@v4
75133 with:
76 name: build-results
77 path: BuildResults.xcresult
134 name: test-results-${{ matrix.tier }}
135 path: TestResults.xcresult
78136 retention-days: 7
DomainDig.xcodeproj/project.pbxproj +118 −2
@@ -33,6 +33,13 @@
3333 remoteGlobalIDString = 8BDB00010000000000000006;
3434 remoteInfo = DomainDigShareExtension;
3535 };
36 8BDC00010000000000000003 /* PBXContainerItemProxy */ = {
37 isa = PBXContainerItemProxy;
38 containerPortal = 8B7800612F6090E300933221 /* Project object */;
39 proxyType = 1;
40 remoteGlobalIDString = 8B7800682F6090E300933221;
41 remoteInfo = DomainDig;
42 };
3643/* End PBXContainerItemProxy section */
3744
3845/* Begin PBXCopyFilesBuildPhase section */
@@ -61,6 +68,7 @@
6168 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; };
6269 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
6370 8BDB00010000000000000001 /* DomainDigShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
71 8BDC00010000000000000001 /* DomainDigUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DomainDigUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
6472 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
6573/* End PBXFileReference section */
6674
@@ -120,6 +128,11 @@
120128 path = DomainDigShareExtension;
121129 sourceTree = "<group>";
122130 };
131 8BDC0001000000000000000E /* DomainDigUITests */ = {
132 isa = PBXFileSystemSynchronizedRootGroup;
133 path = DomainDigUITests;
134 sourceTree = "<group>";
135 };
123136/* End PBXFileSystemSynchronizedRootGroup section */
124137
125138/* Begin PBXFrameworksBuildPhase section */
@@ -144,6 +157,13 @@
144157 );
145158 runOnlyForDeploymentPostprocessing = 0;
146159 };
160 8BDC00010000000000000008 /* Frameworks */ = {
161 isa = PBXFrameworksBuildPhase;
162 buildActionMask = 2147483647;
163 files = (
164 );
165 runOnlyForDeploymentPostprocessing = 0;
166 };
147167/* End PBXFrameworksBuildPhase section */
148168
149169/* Begin PBXGroup section */
@@ -155,6 +175,7 @@
155175 8BDA0001000000000000000D /* Shared */,
156176 8BDA0001000000000000000E /* DomainDigWidget */,
157177 8BDB0001000000000000000E /* DomainDigShareExtension */,
178 8BDC0001000000000000000E /* DomainDigUITests */,
158179 8B78006A2F6090E300933221 /* Products */,
159180 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
160181 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
@@ -172,6 +193,7 @@
172193 8B7800692F6090E300933221 /* DomainDig.app */,
173194 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */,
174195 8BDB00010000000000000001 /* DomainDigShareExtension.appex */,
196 8BDC00010000000000000001 /* DomainDigUITests.xctest */,
175197 );
176198 name = Products;
177199 sourceTree = "<group>";
@@ -251,6 +273,29 @@
251273 productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */;
252274 productType = "com.apple.product-type.app-extension";
253275 };
276 8BDC00010000000000000006 /* DomainDigUITests */ = {
277 isa = PBXNativeTarget;
278 buildConfigurationList = 8BDC0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigUITests" */;
279 buildPhases = (
280 8BDC00010000000000000007 /* Sources */,
281 8BDC00010000000000000008 /* Frameworks */,
282 8BDC00010000000000000009 /* Resources */,
283 );
284 buildRules = (
285 );
286 dependencies = (
287 8BDC00010000000000000004 /* PBXTargetDependency */,
288 );
289 fileSystemSynchronizedGroups = (
290 8BDC0001000000000000000E /* DomainDigUITests */,
291 );
292 name = DomainDigUITests;
293 packageProductDependencies = (
294 );
295 productName = DomainDigUITests;
296 productReference = 8BDC00010000000000000001 /* DomainDigUITests.xctest */;
297 productType = "com.apple.product-type.bundle.ui-testing";
298 };
254299/* End PBXNativeTarget section */
255300
256301/* Begin PBXProject section */
@@ -275,6 +320,10 @@
275320 8BDB00010000000000000006 = {
276321 CreatedOnToolsVersion = 26.3;
277322 };
323 8BDC00010000000000000006 = {
324 CreatedOnToolsVersion = 27.0;
325 TestTargetID = 8B7800682F6090E300933221;
326 };
278327 };
279328 };
280329 buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */;
@@ -294,6 +343,7 @@
294343 8B7800682F6090E300933221 /* DomainDig */,
295344 8BDA00010000000000000006 /* DomainDigWidgetExtension */,
296345 8BDB00010000000000000006 /* DomainDigShareExtension */,
346 8BDC00010000000000000006 /* DomainDigUITests */,
297347 );
298348 };
299349/* End PBXProject section */
@@ -320,6 +370,13 @@
320370 );
321371 runOnlyForDeploymentPostprocessing = 0;
322372 };
373 8BDC00010000000000000009 /* Resources */ = {
374 isa = PBXResourcesBuildPhase;
375 buildActionMask = 2147483647;
376 files = (
377 );
378 runOnlyForDeploymentPostprocessing = 0;
379 };
323380/* End PBXResourcesBuildPhase section */
324381
325382/* Begin PBXSourcesBuildPhase section */
@@ -351,6 +408,13 @@
351408 );
352409 runOnlyForDeploymentPostprocessing = 0;
353410 };
411 8BDC00010000000000000007 /* Sources */ = {
412 isa = PBXSourcesBuildPhase;
413 buildActionMask = 2147483647;
414 files = (
415 );
416 runOnlyForDeploymentPostprocessing = 0;
417 };
354418/* End PBXSourcesBuildPhase section */
355419
356420/* Begin PBXTargetDependency section */
@@ -364,6 +428,11 @@
364428 target = 8BDB00010000000000000006 /* DomainDigShareExtension */;
365429 targetProxy = 8BDB00010000000000000003 /* PBXContainerItemProxy */;
366430 };
431 8BDC00010000000000000004 /* PBXTargetDependency */ = {
432 isa = PBXTargetDependency;
433 target = 8B7800682F6090E300933221 /* DomainDig */;
434 targetProxy = 8BDC00010000000000000003 /* PBXContainerItemProxy */;
435 };
367436/* End PBXTargetDependency section */
368437
369438/* Begin XCBuildConfiguration section */
@@ -420,7 +489,7 @@
420489 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
421490 GCC_WARN_UNUSED_FUNCTION = YES;
422491 GCC_WARN_UNUSED_VARIABLE = YES;
423 IPHONEOS_DEPLOYMENT_TARGET = 26.2;
492 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
424493 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
425494 MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
426495 MTL_FAST_MATH = YES;
@@ -479,7 +548,7 @@
479548 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
480549 GCC_WARN_UNUSED_FUNCTION = YES;
481550 GCC_WARN_UNUSED_VARIABLE = YES;
482 IPHONEOS_DEPLOYMENT_TARGET = 26.2;
551 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
483552 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
484553 MTL_ENABLE_DEBUG_INFO = NO;
485554 MTL_FAST_MATH = YES;
@@ -680,6 +749,44 @@
680749 };
681750 name = Release;
682751 };
752 8BDC0001000000000000000B /* Debug */ = {
753 isa = XCBuildConfiguration;
754 buildSettings = {
755 CODE_SIGN_STYLE = Automatic;
756 CURRENT_PROJECT_VERSION = 43;
757 DEVELOPMENT_TEAM = ZCNAX3VL9D;
758 GENERATE_INFOPLIST_FILE = YES;
759 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
760 MARKETING_VERSION = 4.8.3;
761 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests;
762 PRODUCT_NAME = "$(TARGET_NAME)";
763 SWIFT_APPROACHABLE_CONCURRENCY = YES;
764 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
765 SWIFT_VERSION = 5.0;
766 TARGETED_DEVICE_FAMILY = "1,2";
767 TEST_TARGET_NAME = DomainDig;
768 };
769 name = Debug;
770 };
771 8BDC0001000000000000000C /* Release */ = {
772 isa = XCBuildConfiguration;
773 buildSettings = {
774 CODE_SIGN_STYLE = Automatic;
775 CURRENT_PROJECT_VERSION = 43;
776 DEVELOPMENT_TEAM = ZCNAX3VL9D;
777 GENERATE_INFOPLIST_FILE = YES;
778 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
779 MARKETING_VERSION = 4.8.3;
780 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests;
781 PRODUCT_NAME = "$(TARGET_NAME)";
782 SWIFT_APPROACHABLE_CONCURRENCY = YES;
783 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
784 SWIFT_VERSION = 5.0;
785 TARGETED_DEVICE_FAMILY = "1,2";
786 TEST_TARGET_NAME = DomainDig;
787 };
788 name = Release;
789 };
683790/* End XCBuildConfiguration section */
684791
685792/* Begin XCConfigurationList section */
@@ -719,6 +826,15 @@
719826 defaultConfigurationIsVisible = 0;
720827 defaultConfigurationName = Release;
721828 };
829 8BDC0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigUITests" */ = {
830 isa = XCConfigurationList;
831 buildConfigurations = (
832 8BDC0001000000000000000B /* Debug */,
833 8BDC0001000000000000000C /* Release */,
834 );
835 defaultConfigurationIsVisible = 0;
836 defaultConfigurationName = Release;
837 };
722838/* End XCConfigurationList section */
723839 };
724840 rootObject = 8B7800612F6090E300933221 /* Project object */;
DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme +12
@@ -29,6 +29,18 @@
2929 selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
3030 shouldUseLaunchSchemeArgsEnv = "YES"
3131 shouldAutocreateTestPlan = "YES">
32 <Testables>
33 <TestableReference
34 skipped = "NO">
35 <BuildableReference
36 BuildableIdentifier = "primary"
37 BlueprintIdentifier = "8BDC00010000000000000006"
38 BuildableName = "DomainDigUITests.xctest"
39 BlueprintName = "DomainDigUITests"
40 ReferencedContainer = "container:DomainDig.xcodeproj">
41 </BuildableReference>
42 </TestableReference>
43 </Testables>
3244 </TestAction>
3345 <LaunchAction
3446 buildConfiguration = "Debug"
DomainDigUITests/AccessibilityAuditHarness.swift added +121
@@ -0,0 +1,121 @@
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. Everything else is reported only.
32 ///
33 /// Empty until the accessibility pass starts landing. Suggested ratchet,
34 /// following the phases in issue #21:
35 ///
36 /// - after phase 2 (semantic colors + light mode): `.contrast`
37 /// - after phase 3 (Dynamic Type + reflow): `.textClipped`, `.dynamicType`,
38 /// `.hitRegion`
39 /// - after phase 4 (VoiceOver): `.elementDetection`,
40 /// `.sufficientElementDescription`, `.trait`
41 static let enforcedAuditTypes: XCUIAccessibilityAuditType = []
42
43 /// Launches the app with feature gating lifted, optionally at a specific
44 /// content size category.
45 static func launch(contentSizeCategory: String? = nil) -> XCUIApplication {
46 let app = XCUIApplication()
47 app.launchArguments = [forceProPlusArgument]
48 if let contentSizeCategory {
49 app.launchArguments += ["-UIPreferredContentSizeCategoryName", contentSizeCategory]
50 }
51 app.launch()
52 return app
53 }
54
55 /// Runs a full audit and records every finding against the test.
56 ///
57 /// Findings are logged and attached to the result bundle so a CI run
58 /// produces the burndown list as an artifact rather than only a pass/fail.
59 static func audit(
60 _ app: XCUIApplication,
61 screen: String,
62 test: XCTestCase
63 ) throws {
64 var findings: [String] = []
65
66 try app.performAccessibilityAudit { issue in
67 let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty
68 let marker = isEnforced ? "FAIL" : "report"
69 findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription)")
70 // true suppresses the finding, false reports it as a test failure.
71 return !isEnforced
72 }
73
74 let summary = findings.isEmpty
75 ? "\(screen): no accessibility findings"
76 : "\(screen): \(findings.count) finding(s)\n" + findings.sorted().map { " • \($0)" }.joined(separator: "\n")
77
78 print(summary)
79
80 let attachment = XCTAttachment(string: summary)
81 attachment.name = "a11y-audit-\(screen)"
82 attachment.lifetime = .keepAlways
83 test.add(attachment)
84 }
85
86 /// `XCUIAccessibilityAuditType` is an option set whose description is just a
87 /// raw bitmask, which makes the burndown list unreadable. Resolve it against
88 /// the named members rather than hard-coding bit positions, so this keeps
89 /// working if Apple adds audit types.
90 private static func name(for type: XCUIAccessibilityAuditType) -> String {
91 let known: [(XCUIAccessibilityAuditType, String)] = [
92 (.contrast, "contrast"),
93 (.elementDetection, "elementDetection"),
94 (.hitRegion, "hitRegion"),
95 (.sufficientElementDescription, "sufficientElementDescription"),
96 (.dynamicType, "dynamicType"),
97 (.textClipped, "textClipped"),
98 (.trait, "trait")
99 ]
100 let matched = known.filter { type.contains($0.0) }.map(\.1)
101 return matched.isEmpty ? "unknown(\(type.rawValue))" : matched.joined(separator: "+")
102 }
103}
104
105extension XCUIApplication {
106 /// Taps a root tab by its visible label.
107 ///
108 /// Falls back to a plain button query because the tab bar is only present in
109 /// the compact size class — in regular width `RootTabView` renders a
110 /// `NavigationSplitView` sidebar instead.
111 @MainActor
112 func selectRootTab(_ name: String) {
113 let tabButton = tabBars.buttons[name]
114 let element = tabButton.waitForExistence(timeout: 5) ? tabButton : buttons[name]
115 XCTAssertTrue(
116 element.waitForExistence(timeout: 5),
117 "Could not find a way to reach the \(name) screen"
118 )
119 element.tap()
120 }
121}
DomainDigUITests/AccessibilityAuditTests.swift added +84
@@ -0,0 +1,84 @@
1import XCTest
2
3/// One accessibility audit per primary screen, plus a Dynamic Type sweep.
4///
5/// See `AccessibilityAuditHarness` for why these report rather than fail by
6/// default, and how to make them enforcing.
7@MainActor
8final class AccessibilityAuditTests: XCTestCase {
9 override func setUp() {
10 // Keep going after a failure so an enforced audit still collects and
11 // attaches every finding. With this off, XCTest aborts at the first
12 // reported issue and the burndown list is lost precisely when a category
13 // is being enforced.
14 continueAfterFailure = true
15 }
16
17 // MARK: Per-screen audits
18
19 func testInspectScreen() throws {
20 let app = AccessibilityAuditHarness.launch()
21 app.selectRootTab("Inspect")
22 try AccessibilityAuditHarness.audit(app, screen: "inspect", test: self)
23 }
24
25 func testDashboardScreen() throws {
26 let app = AccessibilityAuditHarness.launch()
27 app.selectRootTab("Dashboard")
28 try AccessibilityAuditHarness.audit(app, screen: "dashboard", test: self)
29 }
30
31 func testAuditScreen() throws {
32 let app = AccessibilityAuditHarness.launch()
33 app.selectRootTab("Audit")
34 try AccessibilityAuditHarness.audit(app, screen: "audit", test: self)
35 }
36
37 func testHistoryScreen() throws {
38 let app = AccessibilityAuditHarness.launch()
39 app.selectRootTab("History")
40 try AccessibilityAuditHarness.audit(app, screen: "history", test: self)
41 }
42
43 func testSettingsScreen() throws {
44 let app = AccessibilityAuditHarness.launch()
45 app.selectRootTab("Settings")
46 try AccessibilityAuditHarness.audit(app, screen: "settings", test: self)
47 }
48
49 func testTrackedDomainsScreen() throws {
50 let app = AccessibilityAuditHarness.launch()
51 app.selectRootTab("Settings")
52
53 let trackedDomains = app.buttons["Tracked Domains"]
54 XCTAssertTrue(
55 trackedDomains.waitForExistence(timeout: 5),
56 "Settings no longer offers a Tracked Domains row"
57 )
58 trackedDomains.tap()
59
60 try AccessibilityAuditHarness.audit(app, screen: "tracked-domains", test: self)
61 }
62
63 // MARK: Dynamic Type
64
65 /// Re-audits every root screen at the largest accessibility content size.
66 ///
67 /// This is where clipped text and fixed-height containers surface — the
68 /// `.accessibility5`-class failures that the fixed geometry in
69 /// `AppDensityMetrics` is expected to produce until phase 3 of #21 lands.
70 func testAllScreensAtLargestAccessibilitySize() throws {
71 let app = AccessibilityAuditHarness.launch(
72 contentSizeCategory: "UICTContentSizeCategoryAccessibilityXXXL"
73 )
74
75 for tab in ["Inspect", "Dashboard", "Audit", "History", "Settings"] {
76 app.selectRootTab(tab)
77 try AccessibilityAuditHarness.audit(
78 app,
79 screen: "\(tab.lowercased())-accessibilityXXXL",
80 test: self
81 )
82 }
83 }
84}