Commit 38723030eb
Unsigned
Layout: unified · split
.github/workflows/build.yml +80 −22
| @@ -1,13 +1,37 @@ | ||
| 1 | 1 | name: Build |
| 2 | 2 | |
| 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. | |
| 6 | 6 | # |
| 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. | |
| 11 | 35 | # |
| 12 | 36 | # pull_request only, plus manual dispatch. GitHub builds the merge result (PR |
| 13 | 37 | # merged into main), so a green PR validates exactly what will land on main. |
| @@ -31,13 +55,25 @@ concurrency: | ||
| 31 | 55 | group: build-${{ github.ref }} |
| 32 | 56 | cancel-in-progress: true |
| 33 | 57 | |
| 58 | env: | |
| 59 | # Keep in sync with IPHONEOS_DEPLOYMENT_TARGET in DomainDig.xcodeproj. | |
| 60 | DEPLOYMENT_TARGET_MAJOR: '17' | |
| 61 | DEPLOYMENT_TARGET_MINOR: '6' | |
| 62 | ||
| 34 | 63 | jobs: |
| 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. | |
| 39 | 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 | 77 | steps: |
| 42 | 78 | - uses: actions/checkout@v7 |
| 43 | 79 | |
| @@ -50,29 +86,51 @@ jobs: | ||
| 50 | 86 | id: sim |
| 51 | 87 | run: | |
| 52 | 88 | 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" | |
| 57 | 109 | xcrun simctl list devices available >&2 |
| 58 | 110 | exit 1 |
| 59 | 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 | 119 | run: | |
| 64 | 120 | set -o pipefail |
| 65 | xcodebuild build \ | |
| 121 | xcodebuild test \ | |
| 66 | 122 | -project DomainDig.xcodeproj \ |
| 67 | 123 | -scheme DomainDig \ |
| 68 | 124 | -destination "id=${{ steps.sim.outputs.udid }}" \ |
| 69 | -resultBundlePath BuildResults.xcresult \ | |
| 125 | -resultBundlePath TestResults.xcresult \ | |
| 70 | 126 | CODE_SIGNING_ALLOWED=NO |
| 71 | 127 | |
| 72 | 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 | 132 | uses: actions/upload-artifact@v4 |
| 75 | 133 | with: |
| 76 | name: build-results | |
| 77 | path: BuildResults.xcresult | |
| 134 | name: test-results-${{ matrix.tier }} | |
| 135 | path: TestResults.xcresult | |
| 78 | 136 | retention-days: 7 |
DomainDig.xcodeproj/project.pbxproj +118 −2
| @@ -33,6 +33,13 @@ | ||
| 33 | 33 | remoteGlobalIDString = 8BDB00010000000000000006; |
| 34 | 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 | 43 | /* End PBXContainerItemProxy section */ |
| 37 | 44 | |
| 38 | 45 | /* Begin PBXCopyFilesBuildPhase section */ |
| @@ -61,6 +68,7 @@ | ||
| 61 | 68 | 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; }; |
| 62 | 69 | 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 63 | 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 | 72 | 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; |
| 65 | 73 | /* End PBXFileReference section */ |
| 66 | 74 | |
| @@ -120,6 +128,11 @@ | ||
| 120 | 128 | path = DomainDigShareExtension; |
| 121 | 129 | sourceTree = "<group>"; |
| 122 | 130 | }; |
| 131 | 8BDC0001000000000000000E /* DomainDigUITests */ = { | |
| 132 | isa = PBXFileSystemSynchronizedRootGroup; | |
| 133 | path = DomainDigUITests; | |
| 134 | sourceTree = "<group>"; | |
| 135 | }; | |
| 123 | 136 | /* End PBXFileSystemSynchronizedRootGroup section */ |
| 124 | 137 | |
| 125 | 138 | /* Begin PBXFrameworksBuildPhase section */ |
| @@ -144,6 +157,13 @@ | ||
| 144 | 157 | ); |
| 145 | 158 | runOnlyForDeploymentPostprocessing = 0; |
| 146 | 159 | }; |
| 160 | 8BDC00010000000000000008 /* Frameworks */ = { | |
| 161 | isa = PBXFrameworksBuildPhase; | |
| 162 | buildActionMask = 2147483647; | |
| 163 | files = ( | |
| 164 | ); | |
| 165 | runOnlyForDeploymentPostprocessing = 0; | |
| 166 | }; | |
| 147 | 167 | /* End PBXFrameworksBuildPhase section */ |
| 148 | 168 | |
| 149 | 169 | /* Begin PBXGroup section */ |
| @@ -155,6 +175,7 @@ | ||
| 155 | 175 | 8BDA0001000000000000000D /* Shared */, |
| 156 | 176 | 8BDA0001000000000000000E /* DomainDigWidget */, |
| 157 | 177 | 8BDB0001000000000000000E /* DomainDigShareExtension */, |
| 178 | 8BDC0001000000000000000E /* DomainDigUITests */, | |
| 158 | 179 | 8B78006A2F6090E300933221 /* Products */, |
| 159 | 180 | 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */, |
| 160 | 181 | 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */, |
| @@ -172,6 +193,7 @@ | ||
| 172 | 193 | 8B7800692F6090E300933221 /* DomainDig.app */, |
| 173 | 194 | 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */, |
| 174 | 195 | 8BDB00010000000000000001 /* DomainDigShareExtension.appex */, |
| 196 | 8BDC00010000000000000001 /* DomainDigUITests.xctest */, | |
| 175 | 197 | ); |
| 176 | 198 | name = Products; |
| 177 | 199 | sourceTree = "<group>"; |
| @@ -251,6 +273,29 @@ | ||
| 251 | 273 | productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; |
| 252 | 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 | 299 | /* End PBXNativeTarget section */ |
| 255 | 300 | |
| 256 | 301 | /* Begin PBXProject section */ |
| @@ -275,6 +320,10 @@ | ||
| 275 | 320 | 8BDB00010000000000000006 = { |
| 276 | 321 | CreatedOnToolsVersion = 26.3; |
| 277 | 322 | }; |
| 323 | 8BDC00010000000000000006 = { | |
| 324 | CreatedOnToolsVersion = 27.0; | |
| 325 | TestTargetID = 8B7800682F6090E300933221; | |
| 326 | }; | |
| 278 | 327 | }; |
| 279 | 328 | }; |
| 280 | 329 | buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */; |
| @@ -294,6 +343,7 @@ | ||
| 294 | 343 | 8B7800682F6090E300933221 /* DomainDig */, |
| 295 | 344 | 8BDA00010000000000000006 /* DomainDigWidgetExtension */, |
| 296 | 345 | 8BDB00010000000000000006 /* DomainDigShareExtension */, |
| 346 | 8BDC00010000000000000006 /* DomainDigUITests */, | |
| 297 | 347 | ); |
| 298 | 348 | }; |
| 299 | 349 | /* End PBXProject section */ |
| @@ -320,6 +370,13 @@ | ||
| 320 | 370 | ); |
| 321 | 371 | runOnlyForDeploymentPostprocessing = 0; |
| 322 | 372 | }; |
| 373 | 8BDC00010000000000000009 /* Resources */ = { | |
| 374 | isa = PBXResourcesBuildPhase; | |
| 375 | buildActionMask = 2147483647; | |
| 376 | files = ( | |
| 377 | ); | |
| 378 | runOnlyForDeploymentPostprocessing = 0; | |
| 379 | }; | |
| 323 | 380 | /* End PBXResourcesBuildPhase section */ |
| 324 | 381 | |
| 325 | 382 | /* Begin PBXSourcesBuildPhase section */ |
| @@ -351,6 +408,13 @@ | ||
| 351 | 408 | ); |
| 352 | 409 | runOnlyForDeploymentPostprocessing = 0; |
| 353 | 410 | }; |
| 411 | 8BDC00010000000000000007 /* Sources */ = { | |
| 412 | isa = PBXSourcesBuildPhase; | |
| 413 | buildActionMask = 2147483647; | |
| 414 | files = ( | |
| 415 | ); | |
| 416 | runOnlyForDeploymentPostprocessing = 0; | |
| 417 | }; | |
| 354 | 418 | /* End PBXSourcesBuildPhase section */ |
| 355 | 419 | |
| 356 | 420 | /* Begin PBXTargetDependency section */ |
| @@ -364,6 +428,11 @@ | ||
| 364 | 428 | target = 8BDB00010000000000000006 /* DomainDigShareExtension */; |
| 365 | 429 | targetProxy = 8BDB00010000000000000003 /* PBXContainerItemProxy */; |
| 366 | 430 | }; |
| 431 | 8BDC00010000000000000004 /* PBXTargetDependency */ = { | |
| 432 | isa = PBXTargetDependency; | |
| 433 | target = 8B7800682F6090E300933221 /* DomainDig */; | |
| 434 | targetProxy = 8BDC00010000000000000003 /* PBXContainerItemProxy */; | |
| 435 | }; | |
| 367 | 436 | /* End PBXTargetDependency section */ |
| 368 | 437 | |
| 369 | 438 | /* Begin XCBuildConfiguration section */ |
| @@ -420,7 +489,7 @@ | ||
| 420 | 489 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
| 421 | 490 | GCC_WARN_UNUSED_FUNCTION = YES; |
| 422 | 491 | GCC_WARN_UNUSED_VARIABLE = YES; |
| 423 | IPHONEOS_DEPLOYMENT_TARGET = 26.2; | |
| 492 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | |
| 424 | 493 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; |
| 425 | 494 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; |
| 426 | 495 | MTL_FAST_MATH = YES; |
| @@ -479,7 +548,7 @@ | ||
| 479 | 548 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
| 480 | 549 | GCC_WARN_UNUSED_FUNCTION = YES; |
| 481 | 550 | GCC_WARN_UNUSED_VARIABLE = YES; |
| 482 | IPHONEOS_DEPLOYMENT_TARGET = 26.2; | |
| 551 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | |
| 483 | 552 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; |
| 484 | 553 | MTL_ENABLE_DEBUG_INFO = NO; |
| 485 | 554 | MTL_FAST_MATH = YES; |
| @@ -680,6 +749,44 @@ | ||
| 680 | 749 | }; |
| 681 | 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 | 790 | /* End XCBuildConfiguration section */ |
| 684 | 791 | |
| 685 | 792 | /* Begin XCConfigurationList section */ |
| @@ -719,6 +826,15 @@ | ||
| 719 | 826 | defaultConfigurationIsVisible = 0; |
| 720 | 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 | 838 | /* End XCConfigurationList section */ |
| 723 | 839 | }; |
| 724 | 840 | rootObject = 8B7800612F6090E300933221 /* Project object */; |
DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme +12
| @@ -29,6 +29,18 @@ | ||
| 29 | 29 | selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
| 30 | 30 | shouldUseLaunchSchemeArgsEnv = "YES" |
| 31 | 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 | 44 | </TestAction> |
| 33 | 45 | <LaunchAction |
| 34 | 46 | buildConfiguration = "Debug" |
DomainDigUITests/AccessibilityAuditHarness.swift added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | import 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 | |
| 26 | enum 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 | ||
| 105 | extension 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 @@ | ||
| 1 | import 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 | |
| 8 | final 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 | } | |