Commit 45697fc30d

45697fc30d13a07926d54b419a533db6198def62

parent: 91f4fc6315

Unsigned

cmc <hello@cleberg.net> · 2026-08-23 05:49 UTC

Verify accessibility labels in CI

Two checks, because neither covers the other.

scripts/check_accessibility.py reads every view file and fails on an icon-only
control with no accessibility label. Scoping is by brace span rather than line
proximity: a proximity check got both answers wrong on real files, missing a
label 14 lines up and crediting a control with a modifier belonging to the view
above it. Runs on Linux in seconds, no simulator, no credentials, and sees
screens no UI test navigates to.

HutchUITests is a new UI test target walking the controls on screen, failing on
one that announces nothing or announces an SF Symbol name. This catches what the
source check cannot — a label that compiles but never reaches the accessibility
tree.

Both were confirmed against a deliberately unlabelled button. The source check
caught it; the first UI sweep did not, because an unlabelled icon button does
not report an empty label — SwiftUI leaks the symbol name into the label and the
identifier both, and the draft tested for a dot, which "gearshape" does not have.
The sweep now compares label against identifier, which is exact.

The signed-in sweep needs a real token, since the app has no stub session, and
skips unless HUTCH_TEST_TOKEN is set. A launch argument faking the API would
close that gap; noted in ROADMAP.txt.

Layout: unified · split

.github/workflows/test.yml +12
@@ -38,6 +38,18 @@ concurrency:
38 cancel-in-progress: true 38 cancel-in-progress: true
39 39
40jobs: 40jobs:
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 test: 53 test:
42 name: xcodebuild test 54 name: xcodebuild test
43 # macos-latest still points at macOS 15, which lacks the iOS 26 SDK. 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 objects = { 7 objects = {
8 8
9/* Begin PBXBuildFile section */ 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 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; }; 12 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; };
11 8B2F89672F69DEB900FC0253 /* README.nfo in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.nfo */; }; 13 8B2F89672F69DEB900FC0253 /* README.nfo in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.nfo */; };
12 8B2F89682F69DEB900FC0253 /* SECURITY.txt in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.txt */; }; 14 8B2F89682F69DEB900FC0253 /* SECURITY.txt in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.txt */; };
@@ -24,6 +26,13 @@
24/* End PBXBuildFile section */ 26/* End PBXBuildFile section */
25 27
26/* Begin PBXContainerItemProxy section */ 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 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */ = { 36 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */ = {
28 isa = PBXContainerItemProxy; 37 isa = PBXContainerItemProxy;
29 containerPortal = 8B4B28C92F6704280045FA19 /* Project object */; 38 containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
@@ -63,6 +72,8 @@
63/* End PBXCopyFilesBuildPhase section */ 72/* End PBXCopyFilesBuildPhase section */
64 73
65/* Begin PBXFileReference section */ 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 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; }; 77 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
67 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; }; 78 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
68 8B2F89642F69DEB900FC0253 /* README.nfo */ = {isa = PBXFileReference; lastKnownFileType = text; path = README.nfo; sourceTree = "<group>"; }; 79 8B2F89642F69DEB900FC0253 /* README.nfo */ = {isa = PBXFileReference; lastKnownFileType = text; path = README.nfo; sourceTree = "<group>"; };
@@ -80,6 +91,7 @@
80 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; }; 91 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
81 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 92 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
82 8BE083032F81000100000001 /* HutchSafariExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchSafariExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 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/* End PBXFileReference section */ 95/* End PBXFileReference section */
84 96
85/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 97/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -117,11 +129,15 @@
117 }; 129 };
118 8B8182B82F6B73F3000AE049 /* HutchTests */ = { 130 8B8182B82F6B73F3000AE049 /* HutchTests */ = {
119 isa = PBXFileSystemSynchronizedRootGroup; 131 isa = PBXFileSystemSynchronizedRootGroup;
132 exceptions = (
133 );
120 path = HutchTests; 134 path = HutchTests;
121 sourceTree = "<group>"; 135 sourceTree = "<group>";
122 }; 136 };
123 8BE081F42F80000100000001 /* Shared */ = { 137 8BE081F42F80000100000001 /* Shared */ = {
124 isa = PBXFileSystemSynchronizedRootGroup; 138 isa = PBXFileSystemSynchronizedRootGroup;
139 exceptions = (
140 );
125 path = Shared; 141 path = Shared;
126 sourceTree = "<group>"; 142 sourceTree = "<group>";
127 }; 143 };
@@ -144,6 +160,14 @@
144/* End PBXFileSystemSynchronizedRootGroup section */ 160/* End PBXFileSystemSynchronizedRootGroup section */
145 161
146/* Begin PBXFrameworksBuildPhase section */ 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 8B4B28CE2F6704280045FA19 /* Frameworks */ = { 171 8B4B28CE2F6704280045FA19 /* Frameworks */ = {
148 isa = PBXFrameworksBuildPhase; 172 isa = PBXFrameworksBuildPhase;
149 buildActionMask = 2147483647; 173 buildActionMask = 2147483647;
@@ -177,6 +201,22 @@
177/* End PBXFrameworksBuildPhase section */ 201/* End PBXFrameworksBuildPhase section */
178 202
179/* Begin PBXGroup section */ 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 8B2FA9CB2F7E211800E609E2 /* API */ = { 220 8B2FA9CB2F7E211800E609E2 /* API */ = {
181 isa = PBXGroup; 221 isa = PBXGroup;
182 children = ( 222 children = (
@@ -215,6 +255,8 @@
215 8BE083052F81000100000001 /* HutchSafariExtension */, 255 8BE083052F81000100000001 /* HutchSafariExtension */,
216 8B8182B82F6B73F3000AE049 /* HutchTests */, 256 8B8182B82F6B73F3000AE049 /* HutchTests */,
217 8B4B28D22F6704280045FA19 /* Products */, 257 8B4B28D22F6704280045FA19 /* Products */,
258 53CE95BE8B13B8E6B57E4BBB /* Frameworks */,
259 CF2D242564EB2748C4FE43C7 /* HutchUITests */,
218 ); 260 );
219 sourceTree = "<group>"; 261 sourceTree = "<group>";
220 }; 262 };
@@ -225,13 +267,41 @@
225 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, 267 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */,
226 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */, 268 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */,
227 8BE083032F81000100000001 /* HutchSafariExtension.appex */, 269 8BE083032F81000100000001 /* HutchSafariExtension.appex */,
270 E53BD5EFA4A8857C8E76E77C /* HutchUITests.xctest */,
228 ); 271 );
229 name = Products; 272 name = Products;
230 sourceTree = "<group>"; 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/* End PBXGroup section */ 284/* End PBXGroup section */
233 285
234/* Begin PBXNativeTarget section */ 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 8B4B28D02F6704280045FA19 /* Hutch */ = { 305 8B4B28D02F6704280045FA19 /* Hutch */ = {
236 isa = PBXNativeTarget; 306 isa = PBXNativeTarget;
237 buildConfigurationList = 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */; 307 buildConfigurationList = 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */;
@@ -277,8 +347,6 @@
277 8B8182B82F6B73F3000AE049 /* HutchTests */, 347 8B8182B82F6B73F3000AE049 /* HutchTests */,
278 ); 348 );
279 name = HutchTests; 349 name = HutchTests;
280 packageProductDependencies = (
281 );
282 productName = HutchTests; 350 productName = HutchTests;
283 productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */; 351 productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */;
284 productType = "com.apple.product-type.bundle.unit-test"; 352 productType = "com.apple.product-type.bundle.unit-test";
@@ -300,8 +368,6 @@
300 8BE081F52F80000100000001 /* HutchWidgetExtension */, 368 8BE081F52F80000100000001 /* HutchWidgetExtension */,
301 ); 369 );
302 name = HutchWidgetExtension; 370 name = HutchWidgetExtension;
303 packageProductDependencies = (
304 );
305 productName = HutchWidgetExtension; 371 productName = HutchWidgetExtension;
306 productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; 372 productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */;
307 productType = "com.apple.product-type.app-extension"; 373 productType = "com.apple.product-type.app-extension";
@@ -322,8 +388,6 @@
322 8BE083052F81000100000001 /* HutchSafariExtension */, 388 8BE083052F81000100000001 /* HutchSafariExtension */,
323 ); 389 );
324 name = HutchSafariExtension; 390 name = HutchSafariExtension;
325 packageProductDependencies = (
326 );
327 productName = HutchSafariExtension; 391 productName = HutchSafariExtension;
328 productReference = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; 392 productReference = 8BE083032F81000100000001 /* HutchSafariExtension.appex */;
329 productType = "com.apple.product-type.app-extension"; 393 productType = "com.apple.product-type.app-extension";
@@ -372,11 +436,19 @@
372 8BE081F72F80000100000001 /* HutchWidgetExtension */, 436 8BE081F72F80000100000001 /* HutchWidgetExtension */,
373 8BE083092F81000100000001 /* HutchSafariExtension */, 437 8BE083092F81000100000001 /* HutchSafariExtension */,
374 8B8182B62F6B73F3000AE049 /* HutchTests */, 438 8B8182B62F6B73F3000AE049 /* HutchTests */,
439 55C58E61B9D46807C2524ABE /* HutchUITests */,
375 ); 440 );
376 }; 441 };
377/* End PBXProject section */ 442/* End PBXProject section */
378 443
379/* Begin PBXResourcesBuildPhase section */ 444/* Begin PBXResourcesBuildPhase section */
445 6693997E5CA01B62C500224F /* Resources */ = {
446 isa = PBXResourcesBuildPhase;
447 buildActionMask = 2147483647;
448 files = (
449 );
450 runOnlyForDeploymentPostprocessing = 0;
451 };
380 8B4B28CF2F6704280045FA19 /* Resources */ = { 452 8B4B28CF2F6704280045FA19 /* Resources */ = {
381 isa = PBXResourcesBuildPhase; 453 isa = PBXResourcesBuildPhase;
382 buildActionMask = 2147483647; 454 buildActionMask = 2147483647;
@@ -418,6 +490,14 @@
418/* End PBXResourcesBuildPhase section */ 490/* End PBXResourcesBuildPhase section */
419 491
420/* Begin PBXSourcesBuildPhase section */ 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 8B4B28CD2F6704280045FA19 /* Sources */ = { 501 8B4B28CD2F6704280045FA19 /* Sources */ = {
422 isa = PBXSourcesBuildPhase; 502 isa = PBXSourcesBuildPhase;
423 buildActionMask = 2147483647; 503 buildActionMask = 2147483647;
@@ -449,6 +529,12 @@
449/* End PBXSourcesBuildPhase section */ 529/* End PBXSourcesBuildPhase section */
450 530
451/* Begin PBXTargetDependency section */ 531/* Begin PBXTargetDependency section */
532 425D21A6305B6A50B714F42D /* PBXTargetDependency */ = {
533 isa = PBXTargetDependency;
534 name = Hutch;
535 target = 8B4B28D02F6704280045FA19 /* Hutch */;
536 targetProxy = 104E023C02864EA3BCC1B924 /* PBXContainerItemProxy */;
537 };
452 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */ = { 538 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */ = {
453 isa = PBXTargetDependency; 539 isa = PBXTargetDependency;
454 target = 8B4B28D02F6704280045FA19 /* Hutch */; 540 target = 8B4B28D02F6704280045FA19 /* Hutch */;
@@ -467,6 +553,23 @@
467/* End PBXTargetDependency section */ 553/* End PBXTargetDependency section */
468 554
469/* Begin XCBuildConfiguration section */ 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 8B4B28DA2F6704290045FA19 /* Debug */ = { 573 8B4B28DA2F6704290045FA19 /* Debug */ = {
471 isa = XCBuildConfiguration; 574 isa = XCBuildConfiguration;
472 buildSettings = { 575 buildSettings = {
@@ -824,9 +927,34 @@
824 }; 927 };
825 name = Release; 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/* End XCBuildConfiguration section */ 946/* End XCBuildConfiguration section */
828 947
829/* Begin XCConfigurationList section */ 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 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */ = { 958 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */ = {
831 isa = XCConfigurationList; 959 isa = XCConfigurationList;
832 buildConfigurations = ( 960 buildConfigurations = (
Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme +11
@@ -46,6 +46,17 @@
46 ReferencedContainer = "container:Hutch.xcodeproj"> 46 ReferencedContainer = "container:Hutch.xcodeproj">
47 </BuildableReference> 47 </BuildableReference>
48 </TestableReference> 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 </Testables> 60 </Testables>
50 </TestAction> 61 </TestAction>
51 <LaunchAction 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", 4 "id": "06A589A5-80C7-41FE-911F-03518252CBDD",
5 "name" : "Test Scheme Action", 5 "name": "Test Scheme Action",
6 "options" : { 6 "options": {}
7
8 }
9 } 7 }
10 ], 8 ],
11 "defaultOptions" : { 9 "defaultOptions": {
12 "performanceAntipatternCheckerEnabled" : true, 10 "performanceAntipatternCheckerEnabled": true,
13 "targetForVariableExpansion" : { 11 "targetForVariableExpansion": {
14 "containerPath" : "container:Hutch.xcodeproj", 12 "containerPath": "container:Hutch.xcodeproj",
15 "identifier" : "8B4B28D02F6704280045FA19", 13 "identifier": "8B4B28D02F6704280045FA19",
16 "name" : "Hutch" 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, 27 "parallelizable": false,
22 "target" : { 28 "target": {
23 "containerPath" : "container:Hutch.xcodeproj", 29 "containerPath": "container:Hutch.xcodeproj",
24 "identifier" : "8B8182B62F6B73F3000AE049", 30 "identifier": "55C58E61B9D46807C2524ABE",
25 "name" : "HutchTests" 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
12import 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
17final 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.
232An unlabelled SF Symbol contributes nothing to a combined label, so hiding them 232An unlabelled SF Symbol contributes nothing to a combined label, so hiding them
233would be churn with no announced difference. 233would be churn with no announced difference.
234 234
235Still open: **this is not device-verified.** It builds and the suite is green, 235Two checks now hold this, because either alone is insufficient:
236but neither proves a VoiceOver pass. What is verified is that no icon-only 236
237control is left unlabelled — a static check over the enclosing control's brace 237- `scripts/check_accessibility.py` reads every view file and fails on an
238span, not a guess at line proximity. 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
247The UI sweep covers the auth screen unconditionally. The signed-in tabs need a
248real token — the app has no stub session, so there is no way to reach them
249offline — and that test skips unless `HUTCH_TEST_TOKEN` is set. Adding a launch
250argument that fakes the API would remove that gap and is worth doing before the
251next accessibility pass.
252
253Still open: **this is not device-verified.** Neither check is VoiceOver. They
254prove a control has something to announce, not that the announcement is
255sensible in order, with rotor navigation, at Dynamic Type sizes.
239 256
240### SonarCloud backlog — done in code (v3.8.1) 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
4VoiceOver reaches a ``Button { Image(systemName: "plus") }`` with nothing to
5announce: an SF Symbol carries no label of its own, so the control is reported
6as a bare button. The same button with a ``Text`` beside it is fine, because the
7text becomes the combined label — which is why this only flags controls whose
8label view is icons all the way down.
9
10Scoping is by brace span, not line proximity. An earlier proximity check got
11both answers wrong on real files: it missed a label 14 lines up and it credited
12a control with an unrelated modifier from the view above it.
13
14Run locally with ``python3 scripts/check_accessibility.py``; exits non-zero and
15lists offenders, so the fix is always "label it, or say why it needs none".
16"""
17import re
18import subprocess
19import sys
20from pathlib import Path
21
22# Views only. Networking and model files have no controls to label.
23SOURCE_GLOBS = ["Hutch/**/*.swift", "Shared/*.swift", "HutchWidgetExtension/*.swift"]
24
25CONTROL = re.compile(r"\b(Button|NavigationLink|Menu)\b")
26ACCESSIBILITY = re.compile(r"accessibility(Label|Hidden|Hint|Value|AddTraits)")
27# A visible text view inside the control's label supplies the announcement.
28TEXTUAL = 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.
32LOOKBACK = 25
33MAX_BODY = 80
34
35
36def 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
64def 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
88def 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
105if __name__ == "__main__":
106 sys.exit(main())