Commit 45697fc30d
Unsigned
Layout: unified · split
.github/workflows/test.yml +12
| @@ -38,6 +38,18 @@ concurrency: | ||
| 38 | 38 | cancel-in-progress: true |
| 39 | 39 | |
| 40 | 40 | jobs: |
| 41 | # Runs on Linux because it only reads source. Accessibility labels cannot be | |
| 42 | # checked from a build — the app compiles either way — and a UI test only sees | |
| 43 | # screens it can reach. This sees every view file, in seconds, with no | |
| 44 | # simulator and no credentials. | |
| 45 | accessibility: | |
| 46 | name: accessibility labels | |
| 47 | runs-on: ubuntu-latest | |
| 48 | ||
| 49 | steps: | |
| 50 | - uses: actions/checkout@v7 | |
| 51 | - run: python3 scripts/check_accessibility.py | |
| 52 | ||
| 41 | 53 | test: |
| 42 | 54 | name: xcodebuild test |
| 43 | 55 | # macos-latest still points at macOS 15, which lacks the iOS 26 SDK. |
Hutch.xcodeproj/project.pbxproj +134 −6
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | objects = { |
| 8 | 8 | |
| 9 | 9 | /* Begin PBXBuildFile section */ |
| 10 | 5BCB2F8D3CB7523D13629488 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 81B757B59FDC52273ADF0569 /* Foundation.framework */; }; | |
| 11 | 85EF08B88008A7861BC573CD /* AccessibilityUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36512CB22293FDA01F42B239 /* AccessibilityUITests.swift */; }; | |
| 10 | 12 | 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; }; |
| 11 | 13 | 8B2F89672F69DEB900FC0253 /* README.nfo in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.nfo */; }; |
| 12 | 14 | 8B2F89682F69DEB900FC0253 /* SECURITY.txt in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.txt */; }; |
| @@ -24,6 +26,13 @@ | ||
| 24 | 26 | /* End PBXBuildFile section */ |
| 25 | 27 | |
| 26 | 28 | /* Begin PBXContainerItemProxy section */ |
| 29 | 104E023C02864EA3BCC1B924 /* PBXContainerItemProxy */ = { | |
| 30 | isa = PBXContainerItemProxy; | |
| 31 | containerPortal = 8B4B28C92F6704280045FA19 /* Project object */; | |
| 32 | proxyType = 1; | |
| 33 | remoteGlobalIDString = 8B4B28D02F6704280045FA19; | |
| 34 | remoteInfo = Hutch; | |
| 35 | }; | |
| 27 | 36 | 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */ = { |
| 28 | 37 | isa = PBXContainerItemProxy; |
| 29 | 38 | containerPortal = 8B4B28C92F6704280045FA19 /* Project object */; |
| @@ -63,6 +72,8 @@ | ||
| 63 | 72 | /* End PBXCopyFilesBuildPhase section */ |
| 64 | 73 | |
| 65 | 74 | /* Begin PBXFileReference section */ |
| 75 | 36512CB22293FDA01F42B239 /* AccessibilityUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessibilityUITests.swift; sourceTree = "<group>"; }; | |
| 76 | 81B757B59FDC52273ADF0569 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; | |
| 66 | 77 | 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; }; |
| 67 | 78 | 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; }; |
| 68 | 79 | 8B2F89642F69DEB900FC0253 /* README.nfo */ = {isa = PBXFileReference; lastKnownFileType = text; path = README.nfo; sourceTree = "<group>"; }; |
| @@ -80,6 +91,7 @@ | ||
| 80 | 91 | 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; }; |
| 81 | 92 | 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 82 | 93 | 8BE083032F81000100000001 /* HutchSafariExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchSafariExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 94 | E53BD5EFA4A8857C8E76E77C /* HutchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; | |
| 83 | 95 | /* End PBXFileReference section */ |
| 84 | 96 | |
| 85 | 97 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| @@ -117,11 +129,15 @@ | ||
| 117 | 129 | }; |
| 118 | 130 | 8B8182B82F6B73F3000AE049 /* HutchTests */ = { |
| 119 | 131 | isa = PBXFileSystemSynchronizedRootGroup; |
| 132 | exceptions = ( | |
| 133 | ); | |
| 120 | 134 | path = HutchTests; |
| 121 | 135 | sourceTree = "<group>"; |
| 122 | 136 | }; |
| 123 | 137 | 8BE081F42F80000100000001 /* Shared */ = { |
| 124 | 138 | isa = PBXFileSystemSynchronizedRootGroup; |
| 139 | exceptions = ( | |
| 140 | ); | |
| 125 | 141 | path = Shared; |
| 126 | 142 | sourceTree = "<group>"; |
| 127 | 143 | }; |
| @@ -144,6 +160,14 @@ | ||
| 144 | 160 | /* End PBXFileSystemSynchronizedRootGroup section */ |
| 145 | 161 | |
| 146 | 162 | /* Begin PBXFrameworksBuildPhase section */ |
| 163 | 45764517796ACFDF6884B202 /* Frameworks */ = { | |
| 164 | isa = PBXFrameworksBuildPhase; | |
| 165 | buildActionMask = 2147483647; | |
| 166 | files = ( | |
| 167 | 5BCB2F8D3CB7523D13629488 /* Foundation.framework in Frameworks */, | |
| 168 | ); | |
| 169 | runOnlyForDeploymentPostprocessing = 0; | |
| 170 | }; | |
| 147 | 171 | 8B4B28CE2F6704280045FA19 /* Frameworks */ = { |
| 148 | 172 | isa = PBXFrameworksBuildPhase; |
| 149 | 173 | buildActionMask = 2147483647; |
| @@ -177,6 +201,22 @@ | ||
| 177 | 201 | /* End PBXFrameworksBuildPhase section */ |
| 178 | 202 | |
| 179 | 203 | /* Begin PBXGroup section */ |
| 204 | 53CE95BE8B13B8E6B57E4BBB /* Frameworks */ = { | |
| 205 | isa = PBXGroup; | |
| 206 | children = ( | |
| 207 | 7631E8C6830506A8C2FD3DAF /* iOS */, | |
| 208 | ); | |
| 209 | name = Frameworks; | |
| 210 | sourceTree = "<group>"; | |
| 211 | }; | |
| 212 | 7631E8C6830506A8C2FD3DAF /* iOS */ = { | |
| 213 | isa = PBXGroup; | |
| 214 | children = ( | |
| 215 | 81B757B59FDC52273ADF0569 /* Foundation.framework */, | |
| 216 | ); | |
| 217 | name = iOS; | |
| 218 | sourceTree = "<group>"; | |
| 219 | }; | |
| 180 | 220 | 8B2FA9CB2F7E211800E609E2 /* API */ = { |
| 181 | 221 | isa = PBXGroup; |
| 182 | 222 | children = ( |
| @@ -215,6 +255,8 @@ | ||
| 215 | 255 | 8BE083052F81000100000001 /* HutchSafariExtension */, |
| 216 | 256 | 8B8182B82F6B73F3000AE049 /* HutchTests */, |
| 217 | 257 | 8B4B28D22F6704280045FA19 /* Products */, |
| 258 | 53CE95BE8B13B8E6B57E4BBB /* Frameworks */, | |
| 259 | CF2D242564EB2748C4FE43C7 /* HutchUITests */, | |
| 218 | 260 | ); |
| 219 | 261 | sourceTree = "<group>"; |
| 220 | 262 | }; |
| @@ -225,13 +267,41 @@ | ||
| 225 | 267 | 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, |
| 226 | 268 | 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */, |
| 227 | 269 | 8BE083032F81000100000001 /* HutchSafariExtension.appex */, |
| 270 | E53BD5EFA4A8857C8E76E77C /* HutchUITests.xctest */, | |
| 228 | 271 | ); |
| 229 | 272 | name = Products; |
| 230 | 273 | sourceTree = "<group>"; |
| 231 | 274 | }; |
| 275 | CF2D242564EB2748C4FE43C7 /* HutchUITests */ = { | |
| 276 | isa = PBXGroup; | |
| 277 | children = ( | |
| 278 | 36512CB22293FDA01F42B239 /* AccessibilityUITests.swift */, | |
| 279 | ); | |
| 280 | name = HutchUITests; | |
| 281 | path = HutchUITests; | |
| 282 | sourceTree = SOURCE_ROOT; | |
| 283 | }; | |
| 232 | 284 | /* End PBXGroup section */ |
| 233 | 285 | |
| 234 | 286 | /* Begin PBXNativeTarget section */ |
| 287 | 55C58E61B9D46807C2524ABE /* HutchUITests */ = { | |
| 288 | isa = PBXNativeTarget; | |
| 289 | buildConfigurationList = 17E0153B64F83AB0AB28E4EC /* Build configuration list for PBXNativeTarget "HutchUITests" */; | |
| 290 | buildPhases = ( | |
| 291 | 7D4FCDD80891AAAA803FF5A8 /* Sources */, | |
| 292 | 45764517796ACFDF6884B202 /* Frameworks */, | |
| 293 | 6693997E5CA01B62C500224F /* Resources */, | |
| 294 | ); | |
| 295 | buildRules = ( | |
| 296 | ); | |
| 297 | dependencies = ( | |
| 298 | 425D21A6305B6A50B714F42D /* PBXTargetDependency */, | |
| 299 | ); | |
| 300 | name = HutchUITests; | |
| 301 | productName = HutchUITests; | |
| 302 | productReference = E53BD5EFA4A8857C8E76E77C /* HutchUITests.xctest */; | |
| 303 | productType = "com.apple.product-type.bundle.ui-testing"; | |
| 304 | }; | |
| 235 | 305 | 8B4B28D02F6704280045FA19 /* Hutch */ = { |
| 236 | 306 | isa = PBXNativeTarget; |
| 237 | 307 | buildConfigurationList = 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */; |
| @@ -277,8 +347,6 @@ | ||
| 277 | 347 | 8B8182B82F6B73F3000AE049 /* HutchTests */, |
| 278 | 348 | ); |
| 279 | 349 | name = HutchTests; |
| 280 | packageProductDependencies = ( | |
| 281 | ); | |
| 282 | 350 | productName = HutchTests; |
| 283 | 351 | productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */; |
| 284 | 352 | productType = "com.apple.product-type.bundle.unit-test"; |
| @@ -300,8 +368,6 @@ | ||
| 300 | 368 | 8BE081F52F80000100000001 /* HutchWidgetExtension */, |
| 301 | 369 | ); |
| 302 | 370 | name = HutchWidgetExtension; |
| 303 | packageProductDependencies = ( | |
| 304 | ); | |
| 305 | 371 | productName = HutchWidgetExtension; |
| 306 | 372 | productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; |
| 307 | 373 | productType = "com.apple.product-type.app-extension"; |
| @@ -322,8 +388,6 @@ | ||
| 322 | 388 | 8BE083052F81000100000001 /* HutchSafariExtension */, |
| 323 | 389 | ); |
| 324 | 390 | name = HutchSafariExtension; |
| 325 | packageProductDependencies = ( | |
| 326 | ); | |
| 327 | 391 | productName = HutchSafariExtension; |
| 328 | 392 | productReference = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; |
| 329 | 393 | productType = "com.apple.product-type.app-extension"; |
| @@ -372,11 +436,19 @@ | ||
| 372 | 436 | 8BE081F72F80000100000001 /* HutchWidgetExtension */, |
| 373 | 437 | 8BE083092F81000100000001 /* HutchSafariExtension */, |
| 374 | 438 | 8B8182B62F6B73F3000AE049 /* HutchTests */, |
| 439 | 55C58E61B9D46807C2524ABE /* HutchUITests */, | |
| 375 | 440 | ); |
| 376 | 441 | }; |
| 377 | 442 | /* End PBXProject section */ |
| 378 | 443 | |
| 379 | 444 | /* Begin PBXResourcesBuildPhase section */ |
| 445 | 6693997E5CA01B62C500224F /* Resources */ = { | |
| 446 | isa = PBXResourcesBuildPhase; | |
| 447 | buildActionMask = 2147483647; | |
| 448 | files = ( | |
| 449 | ); | |
| 450 | runOnlyForDeploymentPostprocessing = 0; | |
| 451 | }; | |
| 380 | 452 | 8B4B28CF2F6704280045FA19 /* Resources */ = { |
| 381 | 453 | isa = PBXResourcesBuildPhase; |
| 382 | 454 | buildActionMask = 2147483647; |
| @@ -418,6 +490,14 @@ | ||
| 418 | 490 | /* End PBXResourcesBuildPhase section */ |
| 419 | 491 | |
| 420 | 492 | /* Begin PBXSourcesBuildPhase section */ |
| 493 | 7D4FCDD80891AAAA803FF5A8 /* Sources */ = { | |
| 494 | isa = PBXSourcesBuildPhase; | |
| 495 | buildActionMask = 2147483647; | |
| 496 | files = ( | |
| 497 | 85EF08B88008A7861BC573CD /* AccessibilityUITests.swift in Sources */, | |
| 498 | ); | |
| 499 | runOnlyForDeploymentPostprocessing = 0; | |
| 500 | }; | |
| 421 | 501 | 8B4B28CD2F6704280045FA19 /* Sources */ = { |
| 422 | 502 | isa = PBXSourcesBuildPhase; |
| 423 | 503 | buildActionMask = 2147483647; |
| @@ -449,6 +529,12 @@ | ||
| 449 | 529 | /* End PBXSourcesBuildPhase section */ |
| 450 | 530 | |
| 451 | 531 | /* Begin PBXTargetDependency section */ |
| 532 | 425D21A6305B6A50B714F42D /* PBXTargetDependency */ = { | |
| 533 | isa = PBXTargetDependency; | |
| 534 | name = Hutch; | |
| 535 | target = 8B4B28D02F6704280045FA19 /* Hutch */; | |
| 536 | targetProxy = 104E023C02864EA3BCC1B924 /* PBXContainerItemProxy */; | |
| 537 | }; | |
| 452 | 538 | 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */ = { |
| 453 | 539 | isa = PBXTargetDependency; |
| 454 | 540 | target = 8B4B28D02F6704280045FA19 /* Hutch */; |
| @@ -467,6 +553,23 @@ | ||
| 467 | 553 | /* End PBXTargetDependency section */ |
| 468 | 554 | |
| 469 | 555 | /* Begin XCBuildConfiguration section */ |
| 556 | 0695DD4B2F10503E9E898572 /* Release */ = { | |
| 557 | isa = XCBuildConfiguration; | |
| 558 | buildSettings = { | |
| 559 | CLANG_ENABLE_OBJC_WEAK = NO; | |
| 560 | CODE_SIGNING_ALLOWED = NO; | |
| 561 | GENERATE_INFOPLIST_FILE = YES; | |
| 562 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | |
| 563 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchUITests; | |
| 564 | PRODUCT_NAME = "$(TARGET_NAME)"; | |
| 565 | SDKROOT = iphoneos; | |
| 566 | SWIFT_VERSION = 6.0; | |
| 567 | TARGETED_DEVICE_FAMILY = "1,2"; | |
| 568 | TEST_TARGET_NAME = Hutch; | |
| 569 | VALIDATE_PRODUCT = YES; | |
| 570 | }; | |
| 571 | name = Release; | |
| 572 | }; | |
| 470 | 573 | 8B4B28DA2F6704290045FA19 /* Debug */ = { |
| 471 | 574 | isa = XCBuildConfiguration; |
| 472 | 575 | buildSettings = { |
| @@ -824,9 +927,34 @@ | ||
| 824 | 927 | }; |
| 825 | 928 | name = Release; |
| 826 | 929 | }; |
| 930 | CFEEB56B9E52A6AA5A030489 /* Debug */ = { | |
| 931 | isa = XCBuildConfiguration; | |
| 932 | buildSettings = { | |
| 933 | CLANG_ENABLE_OBJC_WEAK = NO; | |
| 934 | CODE_SIGNING_ALLOWED = NO; | |
| 935 | GENERATE_INFOPLIST_FILE = YES; | |
| 936 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | |
| 937 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchUITests; | |
| 938 | PRODUCT_NAME = "$(TARGET_NAME)"; | |
| 939 | SDKROOT = iphoneos; | |
| 940 | SWIFT_VERSION = 6.0; | |
| 941 | TARGETED_DEVICE_FAMILY = "1,2"; | |
| 942 | TEST_TARGET_NAME = Hutch; | |
| 943 | }; | |
| 944 | name = Debug; | |
| 945 | }; | |
| 827 | 946 | /* End XCBuildConfiguration section */ |
| 828 | 947 | |
| 829 | 948 | /* Begin XCConfigurationList section */ |
| 949 | 17E0153B64F83AB0AB28E4EC /* Build configuration list for PBXNativeTarget "HutchUITests" */ = { | |
| 950 | isa = XCConfigurationList; | |
| 951 | buildConfigurations = ( | |
| 952 | 0695DD4B2F10503E9E898572 /* Release */, | |
| 953 | CFEEB56B9E52A6AA5A030489 /* Debug */, | |
| 954 | ); | |
| 955 | defaultConfigurationIsVisible = 0; | |
| 956 | defaultConfigurationName = Release; | |
| 957 | }; | |
| 830 | 958 | 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */ = { |
| 831 | 959 | isa = XCConfigurationList; |
| 832 | 960 | buildConfigurations = ( |
Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme +11
| @@ -46,6 +46,17 @@ | ||
| 46 | 46 | ReferencedContainer = "container:Hutch.xcodeproj"> |
| 47 | 47 | </BuildableReference> |
| 48 | 48 | </TestableReference> |
| 49 | <TestableReference | |
| 50 | skipped = "NO" | |
| 51 | parallelizable = "NO"> | |
| 52 | <BuildableReference | |
| 53 | BuildableIdentifier = "primary" | |
| 54 | BlueprintIdentifier = "55C58E61B9D46807C2524ABE" | |
| 55 | BuildableName = "HutchUITests.xctest" | |
| 56 | BlueprintName = "HutchUITests" | |
| 57 | ReferencedContainer = "container:Hutch.xcodeproj"> | |
| 58 | </BuildableReference> | |
| 59 | </TestableReference> | |
| 49 | 60 | </Testables> |
| 50 | 61 | </TestAction> |
| 51 | 62 | <LaunchAction |
HutchTests.xctestplan +25 −19
| @@ -1,30 +1,36 @@ | ||
| 1 | 1 | { |
| 2 | "configurations" : [ | |
| 2 | "configurations": [ | |
| 3 | 3 | { |
| 4 | "id" : "06A589A5-80C7-41FE-911F-03518252CBDD", | |
| 5 | "name" : "Test Scheme Action", | |
| 6 | "options" : { | |
| 7 | ||
| 8 | } | |
| 4 | "id": "06A589A5-80C7-41FE-911F-03518252CBDD", | |
| 5 | "name": "Test Scheme Action", | |
| 6 | "options": {} | |
| 9 | 7 | } |
| 10 | 8 | ], |
| 11 | "defaultOptions" : { | |
| 12 | "performanceAntipatternCheckerEnabled" : true, | |
| 13 | "targetForVariableExpansion" : { | |
| 14 | "containerPath" : "container:Hutch.xcodeproj", | |
| 15 | "identifier" : "8B4B28D02F6704280045FA19", | |
| 16 | "name" : "Hutch" | |
| 9 | "defaultOptions": { | |
| 10 | "performanceAntipatternCheckerEnabled": true, | |
| 11 | "targetForVariableExpansion": { | |
| 12 | "containerPath": "container:Hutch.xcodeproj", | |
| 13 | "identifier": "8B4B28D02F6704280045FA19", | |
| 14 | "name": "Hutch" | |
| 17 | 15 | } |
| 18 | 16 | }, |
| 19 | "testTargets" : [ | |
| 17 | "testTargets": [ | |
| 18 | { | |
| 19 | "parallelizable": true, | |
| 20 | "target": { | |
| 21 | "containerPath": "container:Hutch.xcodeproj", | |
| 22 | "identifier": "8B8182B62F6B73F3000AE049", | |
| 23 | "name": "HutchTests" | |
| 24 | } | |
| 25 | }, | |
| 20 | 26 | { |
| 21 | "parallelizable" : true, | |
| 22 | "target" : { | |
| 23 | "containerPath" : "container:Hutch.xcodeproj", | |
| 24 | "identifier" : "8B8182B62F6B73F3000AE049", | |
| 25 | "name" : "HutchTests" | |
| 27 | "parallelizable": false, | |
| 28 | "target": { | |
| 29 | "containerPath": "container:Hutch.xcodeproj", | |
| 30 | "identifier": "55C58E61B9D46807C2524ABE", | |
| 31 | "name": "HutchUITests" | |
| 26 | 32 | } |
| 27 | 33 | } |
| 28 | 34 | ], |
| 29 | "version" : 1 | |
| 35 | "version": 1 | |
| 30 | 36 | } |
HutchUITests/AccessibilityUITests.swift added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | // Verifies what a build cannot: that controls reach VoiceOver with something to say. | |
| 2 | // | |
| 3 | // `scripts/check_accessibility.py` proves no icon-only control is missing a label in | |
| 4 | // *source*. It cannot prove the label survives to the accessibility tree — a modifier | |
| 5 | // on the wrong side of a `.buttonStyle`, or a container that flattens its children, | |
| 6 | // compiles and lints clean and still announces nothing. That is what this asserts. | |
| 7 | // | |
| 8 | // The sweep is deliberately generic rather than a list of expected labels. A hardcoded | |
| 9 | // list goes stale the moment a screen changes and tests only what someone remembered to | |
| 10 | // add; walking whatever is on screen catches controls nobody thought about. | |
| 11 | ||
| 12 | import XCTest | |
| 13 | ||
| 14 | // XCUIApplication is MainActor-isolated, and this project builds in Swift 6 language | |
| 15 | // mode, so the whole case is annotated rather than each call hopping actors. | |
| 16 | @MainActor | |
| 17 | final class AccessibilityUITests: XCTestCase { | |
| 18 | ||
| 19 | override func setUp() async throws { | |
| 20 | try await super.setUp() | |
| 21 | continueAfterFailure = false | |
| 22 | } | |
| 23 | ||
| 24 | // MARK: - Reachable without credentials | |
| 25 | ||
| 26 | /// Every control on the auth screen announces itself. | |
| 27 | /// | |
| 28 | /// This is the one screen reachable with no token, so it is the only part of the | |
| 29 | /// sweep that runs unconditionally. It is a thin slice of the app, and the point of | |
| 30 | /// `authenticatedSessionHasNoSilentControls` is to cover the rest. | |
| 31 | func testAuthScreenHasNoSilentControls() { | |
| 32 | let app = XCUIApplication() | |
| 33 | app.launch() | |
| 34 | ||
| 35 | XCTAssertTrue( | |
| 36 | app.buttons.firstMatch.waitForExistence(timeout: 10), | |
| 37 | "the auth screen never appeared, so nothing was verified" | |
| 38 | ) | |
| 39 | assertNoSilentControls(in: app, screen: "auth") | |
| 40 | } | |
| 41 | ||
| 42 | // MARK: - Requires a token | |
| 43 | ||
| 44 | /// The same sweep across the signed-in tabs. | |
| 45 | /// | |
| 46 | /// Skipped unless `HUTCH_TEST_TOKEN` is set, because the app has no stub session: | |
| 47 | /// there is no launch argument that fakes an API, so reaching a signed-in screen | |
| 48 | /// means really signing in. Supply a SourceHut personal access token to run it: | |
| 49 | /// | |
| 50 | /// HUTCH_TEST_TOKEN=… xcodebuild test -scheme Hutch -testPlan HutchUITests … | |
| 51 | /// | |
| 52 | /// The token is read from the environment and never written to the repository. | |
| 53 | func testAuthenticatedSessionHasNoSilentControls() throws { | |
| 54 | let token = ProcessInfo.processInfo.environment["HUTCH_TEST_TOKEN"] | |
| 55 | try XCTSkipIf( | |
| 56 | token?.isEmpty ?? true, | |
| 57 | "set HUTCH_TEST_TOKEN to sweep the signed-in screens" | |
| 58 | ) | |
| 59 | ||
| 60 | let app = XCUIApplication() | |
| 61 | app.launch() | |
| 62 | ||
| 63 | let field = app.secureTextFields.firstMatch.exists | |
| 64 | ? app.secureTextFields.firstMatch | |
| 65 | : app.textFields.firstMatch | |
| 66 | XCTAssertTrue(field.waitForExistence(timeout: 10), "no token field on the auth screen") | |
| 67 | field.tap() | |
| 68 | field.typeText(token!) | |
| 69 | ||
| 70 | app.buttons["Connect"].tap() | |
| 71 | ||
| 72 | // Home is the landing tab; its tab bar is the signal that sign-in completed. | |
| 73 | XCTAssertTrue( | |
| 74 | app.tabBars.firstMatch.waitForExistence(timeout: 30), | |
| 75 | "sign-in did not reach the tab bar — check the token" | |
| 76 | ) | |
| 77 | ||
| 78 | for tab in app.tabBars.buttons.allElementsBoundByIndex { | |
| 79 | guard tab.isHittable else { continue } | |
| 80 | let name = tab.label | |
| 81 | tab.tap() | |
| 82 | _ = app.staticTexts.firstMatch.waitForExistence(timeout: 10) | |
| 83 | assertNoSilentControls(in: app, screen: name) | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | // MARK: - The sweep | |
| 88 | ||
| 89 | /// Fail for any hittable control VoiceOver would reach with no usable label. | |
| 90 | /// | |
| 91 | /// An unlabelled `Button { Image(systemName: "gearshape") }` does not surface as an | |
| 92 | /// empty label — SwiftUI leaks the symbol name into *both* the label and the | |
| 93 | /// identifier, so VoiceOver announces "gearshape". Comparing the two is what detects | |
| 94 | /// it, and it is exact rather than a guess at what a symbol name looks like: an | |
| 95 | /// earlier version tested for a dot and sailed straight past "gearshape". | |
| 96 | /// | |
| 97 | /// This works because the app sets no `accessibilityIdentifier` anywhere, so a | |
| 98 | /// non-empty identifier can only have come from a symbol. Should one ever be set | |
| 99 | /// deliberately, this needs to exclude it. | |
| 100 | private func assertNoSilentControls(in app: XCUIApplication, screen: String) { | |
| 101 | for button in app.buttons.allElementsBoundByIndex { | |
| 102 | guard button.isHittable else { continue } | |
| 103 | ||
| 104 | let label = button.label.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 105 | XCTAssertFalse( | |
| 106 | label.isEmpty, | |
| 107 | "\(screen): a button announces nothing at \(button.frame)" | |
| 108 | ) | |
| 109 | XCTAssertFalse( | |
| 110 | !button.identifier.isEmpty && button.identifier == label, | |
| 111 | "\(screen): a button announces the SF Symbol name \"\(label)\" — " | |
| 112 | + "it needs an .accessibilityLabel" | |
| 113 | ) | |
| 114 | } | |
| 115 | } | |
| 116 | } | |
ROADMAP.txt +21 −4
| @@ -232,10 +232,27 @@ Decorative chevrons inside a control that already carries text were left alone. | ||
| 232 | 232 | An unlabelled SF Symbol contributes nothing to a combined label, so hiding them |
| 233 | 233 | would be churn with no announced difference. |
| 234 | 234 | |
| 235 | Still open: **this is not device-verified.** It builds and the suite is green, | |
| 236 | but neither proves a VoiceOver pass. What is verified is that no icon-only | |
| 237 | control is left unlabelled — a static check over the enclosing control's brace | |
| 238 | span, not a guess at line proximity. | |
| 235 | Two checks now hold this, because either alone is insufficient: | |
| 236 | ||
| 237 | - `scripts/check_accessibility.py` reads every view file and fails on an | |
| 238 | icon-only control with no label. It runs on Linux in seconds, needs no | |
| 239 | simulator and no credentials, and covers screens no test navigates to. What it | |
| 240 | cannot see is whether a label survives to the accessibility tree. | |
| 241 | - `HutchUITests/AccessibilityUITests.swift` walks the controls actually on | |
| 242 | screen and fails on one that announces nothing, or that announces an SF Symbol | |
| 243 | name. Both checks were confirmed against a deliberately unlabelled button; the | |
| 244 | first draft of the UI sweep passed it, which is why the check now compares the | |
| 245 | label against the identifier rather than guessing at symbol-name shape. | |
| 246 | ||
| 247 | The UI sweep covers the auth screen unconditionally. The signed-in tabs need a | |
| 248 | real token — the app has no stub session, so there is no way to reach them | |
| 249 | offline — and that test skips unless `HUTCH_TEST_TOKEN` is set. Adding a launch | |
| 250 | argument that fakes the API would remove that gap and is worth doing before the | |
| 251 | next accessibility pass. | |
| 252 | ||
| 253 | Still open: **this is not device-verified.** Neither check is VoiceOver. They | |
| 254 | prove a control has something to announce, not that the announcement is | |
| 255 | sensible in order, with rotor navigation, at Dynamic Type sizes. | |
| 239 | 256 | |
| 240 | 257 | ### SonarCloud backlog — done in code (v3.8.1) |
| 241 | 258 | |
scripts/check_accessibility.py added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | #!/usr/bin/env python3 | |
| 2 | """Fail if an icon-only control ships without an accessibility label. | |
| 3 | ||
| 4 | VoiceOver reaches a ``Button { Image(systemName: "plus") }`` with nothing to | |
| 5 | announce: an SF Symbol carries no label of its own, so the control is reported | |
| 6 | as a bare button. The same button with a ``Text`` beside it is fine, because the | |
| 7 | text becomes the combined label — which is why this only flags controls whose | |
| 8 | label view is icons all the way down. | |
| 9 | ||
| 10 | Scoping is by brace span, not line proximity. An earlier proximity check got | |
| 11 | both answers wrong on real files: it missed a label 14 lines up and it credited | |
| 12 | a control with an unrelated modifier from the view above it. | |
| 13 | ||
| 14 | Run locally with ``python3 scripts/check_accessibility.py``; exits non-zero and | |
| 15 | lists offenders, so the fix is always "label it, or say why it needs none". | |
| 16 | """ | |
| 17 | import re | |
| 18 | import subprocess | |
| 19 | import sys | |
| 20 | from pathlib import Path | |
| 21 | ||
| 22 | # Views only. Networking and model files have no controls to label. | |
| 23 | SOURCE_GLOBS = ["Hutch/**/*.swift", "Shared/*.swift", "HutchWidgetExtension/*.swift"] | |
| 24 | ||
| 25 | CONTROL = re.compile(r"\b(Button|NavigationLink|Menu)\b") | |
| 26 | ACCESSIBILITY = re.compile(r"accessibility(Label|Hidden|Hint|Value|AddTraits)") | |
| 27 | # A visible text view inside the control's label supplies the announcement. | |
| 28 | TEXTUAL = re.compile(r"\bText\(|\bLabel\(|Pill\(") | |
| 29 | ||
| 30 | # How far back a control opener may sit, and how long its body may run. Both are | |
| 31 | # generous for SwiftUI; a control longer than this is worth splitting anyway. | |
| 32 | LOOKBACK = 25 | |
| 33 | MAX_BODY = 80 | |
| 34 | ||
| 35 | ||
| 36 | def enclosing_control(lines: list[str], index: int) -> tuple[int, int] | None: | |
| 37 | """Brace span of the nearest control whose body contains ``index``. | |
| 38 | ||
| 39 | Returns the span including the trailing modifier chain, since | |
| 40 | ``.accessibilityLabel`` attaches there rather than inside the label closure. | |
| 41 | """ | |
| 42 | for start in range(index, max(-1, index - LOOKBACK), -1): | |
| 43 | if not CONTROL.search(lines[start]): | |
| 44 | continue | |
| 45 | depth = 0 | |
| 46 | opened = False | |
| 47 | end = None | |
| 48 | for j in range(start, min(len(lines), start + MAX_BODY)): | |
| 49 | depth += lines[j].count("{") - lines[j].count("}") | |
| 50 | if "{" in lines[j]: | |
| 51 | opened = True | |
| 52 | if opened and depth <= 0: | |
| 53 | end = j | |
| 54 | break | |
| 55 | if end is None or end < index: | |
| 56 | continue | |
| 57 | after = end + 1 | |
| 58 | while after < len(lines) and re.match(r"\s*\.\w+", lines[after]): | |
| 59 | after += 1 | |
| 60 | return start, after | |
| 61 | return None | |
| 62 | ||
| 63 | ||
| 64 | def offenders() -> list[tuple[str, int, str]]: | |
| 65 | files = subprocess.run( | |
| 66 | ["git", "ls-files", *SOURCE_GLOBS], | |
| 67 | capture_output=True, | |
| 68 | text=True, | |
| 69 | check=True, | |
| 70 | ).stdout.split() | |
| 71 | ||
| 72 | found = [] | |
| 73 | for path in files: | |
| 74 | lines = Path(path).read_text().splitlines() | |
| 75 | for i, line in enumerate(lines): | |
| 76 | if "Image(systemName:" not in line: | |
| 77 | continue | |
| 78 | span = enclosing_control(lines, i) | |
| 79 | if span is None: | |
| 80 | continue # a decorative image, not a control's label | |
| 81 | body = "\n".join(lines[span[0] : span[1]]) | |
| 82 | if ACCESSIBILITY.search(body) or TEXTUAL.search(body): | |
| 83 | continue | |
| 84 | found.append((path, i + 1, line.strip())) | |
| 85 | return found | |
| 86 | ||
| 87 | ||
| 88 | def main() -> int: | |
| 89 | found = offenders() | |
| 90 | if not found: | |
| 91 | print("No unlabelled icon-only controls.") | |
| 92 | return 0 | |
| 93 | ||
| 94 | print(f"{len(found)} icon-only control(s) reach VoiceOver with no label:\n") | |
| 95 | for path, line, source in found: | |
| 96 | print(f" {path}:{line}") | |
| 97 | print(f" {source}") | |
| 98 | print( | |
| 99 | "\nAdd .accessibilityLabel(\"...\") to the control, or .accessibilityHidden(true)" | |
| 100 | "\nif something else already announces it." | |
| 101 | ) | |
| 102 | return 1 | |
| 103 | ||
| 104 | ||
| 105 | if __name__ == "__main__": | |
| 106 | sys.exit(main()) | |