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 @@
1name: Build 1name: Build
2 2
3# Compile gate for the GitHub mirror. builds.sr.ht is the primary remote for 3# Compile gate and accessibility audit for the GitHub mirror. builds.sr.ht is the
4# this project but has no macOS images, so xcodebuild cannot run there; this job 4# primary remote for this project but has no macOS images, so xcodebuild cannot
5# compiles the app on a GitHub-hosted macOS runner instead. 5# run there; this job builds and tests on a GitHub-hosted macOS runner instead.
6# 6#
7# This runs `xcodebuild build`, not `test`: the project has no test target yet 7# This runs `xcodebuild test`, which also compiles the app, the widget, and the
8# (planned for v5.0.0 in RELEASE_ROADMAP.md). When a test target and test plan 8# share extension (the DomainDig scheme's build action pulls both in as
9# exist, switch the final step to `xcodebuild test -testPlan <name>` and rename 9# dependencies). The test target is DomainDigUITests — an accessibility audit
10# this workflow — the rest of the setup already matches a test run. 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.
11# 35#
12# pull_request only, plus manual dispatch. GitHub builds the merge result (PR 36# pull_request only, plus manual dispatch. GitHub builds the merge result (PR
13# merged into main), so a green PR validates exactly what will land on main. 37# merged into main), so a green PR validates exactly what will land on main.
@@ -31,13 +55,25 @@ concurrency:
31 group: build-${{ github.ref }} 55 group: build-${{ github.ref }}
32 cancel-in-progress: true 56 cancel-in-progress: true
33 57
58env:
59 # Keep in sync with IPHONEOS_DEPLOYMENT_TARGET in DomainDig.xcodeproj.
60 DEPLOYMENT_TARGET_MAJOR: '17'
61 DEPLOYMENT_TARGET_MINOR: '6'
62
34jobs: 63jobs:
35 build: 64 test:
36 name: xcodebuild build 65 name: xcodebuild test (${{ matrix.tier }})
37 # macos-latest still points at macOS 15, which lacks the iOS 26 SDK this app 66 # macos-latest still points at macOS 15, which lacks the iOS 26+ SDK this app
38 # targets (deployment target 26.2). 67 # is built against.
39 runs-on: macos-26 68 runs-on: macos-26
40 69
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
41 steps: 77 steps:
42 - uses: actions/checkout@v7 78 - uses: actions/checkout@v7
43 79
@@ -50,29 +86,51 @@ jobs:
50 id: sim 86 id: sim
51 run: | 87 run: |
52 set -euo pipefail 88 set -euo pipefail
53 udid=$(xcrun simctl list devices available --json \ 89 floor=$(( DEPLOYMENT_TARGET_MAJOR * 1000 + DEPLOYMENT_TARGET_MINOR ))
54 | jq -r '[.devices[][] | select(.name | startswith("iPhone"))] | first | .udid') 90
55 if [ -z "$udid" ] || [ "$udid" = "null" ]; then 91 selected=$(xcrun simctl list devices available --json \
56 echo "No available iPhone simulator on this image" >&2 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"
57 xcrun simctl list devices available >&2 109 xcrun simctl list devices available >&2
58 exit 1 110 exit 1
59 fi 111 fi
60 echo "udid=$udid" >> "$GITHUB_OUTPUT"
61 112
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 }}
63 run: | 119 run: |
64 set -o pipefail 120 set -o pipefail
65 xcodebuild build \ 121 xcodebuild test \
66 -project DomainDig.xcodeproj \ 122 -project DomainDig.xcodeproj \
67 -scheme DomainDig \ 123 -scheme DomainDig \
68 -destination "id=${{ steps.sim.outputs.udid }}" \ 124 -destination "id=${{ steps.sim.outputs.udid }}" \
69 -resultBundlePath BuildResults.xcresult \ 125 -resultBundlePath TestResults.xcresult \
70 CODE_SIGNING_ALLOWED=NO 126 CODE_SIGNING_ALLOWED=NO
71 127
72 - name: Upload results 128 - 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()
74 uses: actions/upload-artifact@v4 132 uses: actions/upload-artifact@v4
75 with: 133 with:
76 name: build-results 134 name: test-results-${{ matrix.tier }}
77 path: BuildResults.xcresult 135 path: TestResults.xcresult
78 retention-days: 7 136 retention-days: 7
DomainDig.xcodeproj/project.pbxproj +118 −2
@@ -33,6 +33,13 @@
33 remoteGlobalIDString = 8BDB00010000000000000006; 33 remoteGlobalIDString = 8BDB00010000000000000006;
34 remoteInfo = DomainDigShareExtension; 34 remoteInfo = DomainDigShareExtension;
35 }; 35 };
36 8BDC00010000000000000003 /* PBXContainerItemProxy */ = {
37 isa = PBXContainerItemProxy;
38 containerPortal = 8B7800612F6090E300933221 /* Project object */;
39 proxyType = 1;
40 remoteGlobalIDString = 8B7800682F6090E300933221;
41 remoteInfo = DomainDig;
42 };
36/* End PBXContainerItemProxy section */ 43/* End PBXContainerItemProxy section */
37 44
38/* Begin PBXCopyFilesBuildPhase section */ 45/* Begin PBXCopyFilesBuildPhase section */
@@ -61,6 +68,7 @@
61 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; }; 68 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; };
62 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 69 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
63 8BDB00010000000000000001 /* DomainDigShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 70 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; };
64 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; 72 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
65/* End PBXFileReference section */ 73/* End PBXFileReference section */
66 74
@@ -120,6 +128,11 @@
120 path = DomainDigShareExtension; 128 path = DomainDigShareExtension;
121 sourceTree = "<group>"; 129 sourceTree = "<group>";
122 }; 130 };
131 8BDC0001000000000000000E /* DomainDigUITests */ = {
132 isa = PBXFileSystemSynchronizedRootGroup;
133 path = DomainDigUITests;
134 sourceTree = "<group>";
135 };
123/* End PBXFileSystemSynchronizedRootGroup section */ 136/* End PBXFileSystemSynchronizedRootGroup section */
124 137
125/* Begin PBXFrameworksBuildPhase section */ 138/* Begin PBXFrameworksBuildPhase section */
@@ -144,6 +157,13 @@
144 ); 157 );
145 runOnlyForDeploymentPostprocessing = 0; 158 runOnlyForDeploymentPostprocessing = 0;
146 }; 159 };
160 8BDC00010000000000000008 /* Frameworks */ = {
161 isa = PBXFrameworksBuildPhase;
162 buildActionMask = 2147483647;
163 files = (
164 );
165 runOnlyForDeploymentPostprocessing = 0;
166 };
147/* End PBXFrameworksBuildPhase section */ 167/* End PBXFrameworksBuildPhase section */
148 168
149/* Begin PBXGroup section */ 169/* Begin PBXGroup section */
@@ -155,6 +175,7 @@
155 8BDA0001000000000000000D /* Shared */, 175 8BDA0001000000000000000D /* Shared */,
156 8BDA0001000000000000000E /* DomainDigWidget */, 176 8BDA0001000000000000000E /* DomainDigWidget */,
157 8BDB0001000000000000000E /* DomainDigShareExtension */, 177 8BDB0001000000000000000E /* DomainDigShareExtension */,
178 8BDC0001000000000000000E /* DomainDigUITests */,
158 8B78006A2F6090E300933221 /* Products */, 179 8B78006A2F6090E300933221 /* Products */,
159 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */, 180 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
160 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */, 181 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
@@ -172,6 +193,7 @@
172 8B7800692F6090E300933221 /* DomainDig.app */, 193 8B7800692F6090E300933221 /* DomainDig.app */,
173 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */, 194 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */,
174 8BDB00010000000000000001 /* DomainDigShareExtension.appex */, 195 8BDB00010000000000000001 /* DomainDigShareExtension.appex */,
196 8BDC00010000000000000001 /* DomainDigUITests.xctest */,
175 ); 197 );
176 name = Products; 198 name = Products;
177 sourceTree = "<group>"; 199 sourceTree = "<group>";
@@ -251,6 +273,29 @@
251 productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; 273 productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */;
252 productType = "com.apple.product-type.app-extension"; 274 productType = "com.apple.product-type.app-extension";
253 }; 275 };
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 };
254/* End PBXNativeTarget section */ 299/* End PBXNativeTarget section */
255 300
256/* Begin PBXProject section */ 301/* Begin PBXProject section */
@@ -275,6 +320,10 @@
275 8BDB00010000000000000006 = { 320 8BDB00010000000000000006 = {
276 CreatedOnToolsVersion = 26.3; 321 CreatedOnToolsVersion = 26.3;
277 }; 322 };
323 8BDC00010000000000000006 = {
324 CreatedOnToolsVersion = 27.0;
325 TestTargetID = 8B7800682F6090E300933221;
326 };
278 }; 327 };
279 }; 328 };
280 buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */; 329 buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */;
@@ -294,6 +343,7 @@
294 8B7800682F6090E300933221 /* DomainDig */, 343 8B7800682F6090E300933221 /* DomainDig */,
295 8BDA00010000000000000006 /* DomainDigWidgetExtension */, 344 8BDA00010000000000000006 /* DomainDigWidgetExtension */,
296 8BDB00010000000000000006 /* DomainDigShareExtension */, 345 8BDB00010000000000000006 /* DomainDigShareExtension */,
346 8BDC00010000000000000006 /* DomainDigUITests */,
297 ); 347 );
298 }; 348 };
299/* End PBXProject section */ 349/* End PBXProject section */
@@ -320,6 +370,13 @@
320 ); 370 );
321 runOnlyForDeploymentPostprocessing = 0; 371 runOnlyForDeploymentPostprocessing = 0;
322 }; 372 };
373 8BDC00010000000000000009 /* Resources */ = {
374 isa = PBXResourcesBuildPhase;
375 buildActionMask = 2147483647;
376 files = (
377 );
378 runOnlyForDeploymentPostprocessing = 0;
379 };
323/* End PBXResourcesBuildPhase section */ 380/* End PBXResourcesBuildPhase section */
324 381
325/* Begin PBXSourcesBuildPhase section */ 382/* Begin PBXSourcesBuildPhase section */
@@ -351,6 +408,13 @@
351 ); 408 );
352 runOnlyForDeploymentPostprocessing = 0; 409 runOnlyForDeploymentPostprocessing = 0;
353 }; 410 };
411 8BDC00010000000000000007 /* Sources */ = {
412 isa = PBXSourcesBuildPhase;
413 buildActionMask = 2147483647;
414 files = (
415 );
416 runOnlyForDeploymentPostprocessing = 0;
417 };
354/* End PBXSourcesBuildPhase section */ 418/* End PBXSourcesBuildPhase section */
355 419
356/* Begin PBXTargetDependency section */ 420/* Begin PBXTargetDependency section */
@@ -364,6 +428,11 @@
364 target = 8BDB00010000000000000006 /* DomainDigShareExtension */; 428 target = 8BDB00010000000000000006 /* DomainDigShareExtension */;
365 targetProxy = 8BDB00010000000000000003 /* PBXContainerItemProxy */; 429 targetProxy = 8BDB00010000000000000003 /* PBXContainerItemProxy */;
366 }; 430 };
431 8BDC00010000000000000004 /* PBXTargetDependency */ = {
432 isa = PBXTargetDependency;
433 target = 8B7800682F6090E300933221 /* DomainDig */;
434 targetProxy = 8BDC00010000000000000003 /* PBXContainerItemProxy */;
435 };
367/* End PBXTargetDependency section */ 436/* End PBXTargetDependency section */
368 437
369/* Begin XCBuildConfiguration section */ 438/* Begin XCBuildConfiguration section */
@@ -420,7 +489,7 @@
420 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 489 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
421 GCC_WARN_UNUSED_FUNCTION = YES; 490 GCC_WARN_UNUSED_FUNCTION = YES;
422 GCC_WARN_UNUSED_VARIABLE = YES; 491 GCC_WARN_UNUSED_VARIABLE = YES;
423 IPHONEOS_DEPLOYMENT_TARGET = 26.2; 492 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
424 LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 493 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
425 MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 494 MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
426 MTL_FAST_MATH = YES; 495 MTL_FAST_MATH = YES;
@@ -479,7 +548,7 @@
479 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 548 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
480 GCC_WARN_UNUSED_FUNCTION = YES; 549 GCC_WARN_UNUSED_FUNCTION = YES;
481 GCC_WARN_UNUSED_VARIABLE = YES; 550 GCC_WARN_UNUSED_VARIABLE = YES;
482 IPHONEOS_DEPLOYMENT_TARGET = 26.2; 551 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
483 LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 552 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
484 MTL_ENABLE_DEBUG_INFO = NO; 553 MTL_ENABLE_DEBUG_INFO = NO;
485 MTL_FAST_MATH = YES; 554 MTL_FAST_MATH = YES;
@@ -680,6 +749,44 @@
680 }; 749 };
681 name = Release; 750 name = Release;
682 }; 751 };
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 };
683/* End XCBuildConfiguration section */ 790/* End XCBuildConfiguration section */
684 791
685/* Begin XCConfigurationList section */ 792/* Begin XCConfigurationList section */
@@ -719,6 +826,15 @@
719 defaultConfigurationIsVisible = 0; 826 defaultConfigurationIsVisible = 0;
720 defaultConfigurationName = Release; 827 defaultConfigurationName = Release;
721 }; 828 };
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 };
722/* End XCConfigurationList section */ 838/* End XCConfigurationList section */
723 }; 839 };
724 rootObject = 8B7800612F6090E300933221 /* Project object */; 840 rootObject = 8B7800612F6090E300933221 /* Project object */;
DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme +12
@@ -29,6 +29,18 @@
29 selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 29 selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30 shouldUseLaunchSchemeArgsEnv = "YES" 30 shouldUseLaunchSchemeArgsEnv = "YES"
31 shouldAutocreateTestPlan = "YES"> 31 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>
32 </TestAction> 44 </TestAction>
33 <LaunchAction 45 <LaunchAction
34 buildConfiguration = "Debug" 46 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}