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:
3838 cancel-in-progress: true
3939
4040jobs:
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
4153 test:
4254 name: xcodebuild test
4355 # macos-latest still points at macOS 15, which lacks the iOS 26 SDK.
Hutch.xcodeproj/project.pbxproj +134 −6
@@ -7,6 +7,8 @@
77 objects = {
88
99/* 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 */; };
1012 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; };
1113 8B2F89672F69DEB900FC0253 /* README.nfo in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.nfo */; };
1214 8B2F89682F69DEB900FC0253 /* SECURITY.txt in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.txt */; };
@@ -24,6 +26,13 @@
2426/* End PBXBuildFile section */
2527
2628/* Begin PBXContainerItemProxy section */
29 104E023C02864EA3BCC1B924 /* PBXContainerItemProxy */ = {
30 isa = PBXContainerItemProxy;
31 containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
32 proxyType = 1;
33 remoteGlobalIDString = 8B4B28D02F6704280045FA19;
34 remoteInfo = Hutch;
35 };
2736 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */ = {
2837 isa = PBXContainerItemProxy;
2938 containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
@@ -63,6 +72,8 @@
6372/* End PBXCopyFilesBuildPhase section */
6473
6574/* 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; };
6677 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
6778 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
6879 8B2F89642F69DEB900FC0253 /* README.nfo */ = {isa = PBXFileReference; lastKnownFileType = text; path = README.nfo; sourceTree = "<group>"; };
@@ -80,6 +91,7 @@
8091 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
8192 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
8293 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; };
8395/* End PBXFileReference section */
8496
8597/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -117,11 +129,15 @@
117129 };
118130 8B8182B82F6B73F3000AE049 /* HutchTests */ = {
119131 isa = PBXFileSystemSynchronizedRootGroup;
132 exceptions = (
133 );
120134 path = HutchTests;
121135 sourceTree = "<group>";
122136 };
123137 8BE081F42F80000100000001 /* Shared */ = {
124138 isa = PBXFileSystemSynchronizedRootGroup;
139 exceptions = (
140 );
125141 path = Shared;
126142 sourceTree = "<group>";
127143 };
@@ -144,6 +160,14 @@
144160/* End PBXFileSystemSynchronizedRootGroup section */
145161
146162/* 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 };
147171 8B4B28CE2F6704280045FA19 /* Frameworks */ = {
148172 isa = PBXFrameworksBuildPhase;
149173 buildActionMask = 2147483647;
@@ -177,6 +201,22 @@
177201/* End PBXFrameworksBuildPhase section */
178202
179203/* 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 };
180220 8B2FA9CB2F7E211800E609E2 /* API */ = {
181221 isa = PBXGroup;
182222 children = (
@@ -215,6 +255,8 @@
215255 8BE083052F81000100000001 /* HutchSafariExtension */,
216256 8B8182B82F6B73F3000AE049 /* HutchTests */,
217257 8B4B28D22F6704280045FA19 /* Products */,
258 53CE95BE8B13B8E6B57E4BBB /* Frameworks */,
259 CF2D242564EB2748C4FE43C7 /* HutchUITests */,
218260 );
219261 sourceTree = "<group>";
220262 };
@@ -225,13 +267,41 @@
225267 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */,
226268 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */,
227269 8BE083032F81000100000001 /* HutchSafariExtension.appex */,
270 E53BD5EFA4A8857C8E76E77C /* HutchUITests.xctest */,
228271 );
229272 name = Products;
230273 sourceTree = "<group>";
231274 };
275 CF2D242564EB2748C4FE43C7 /* HutchUITests */ = {
276 isa = PBXGroup;
277 children = (
278 36512CB22293FDA01F42B239 /* AccessibilityUITests.swift */,
279 );
280 name = HutchUITests;
281 path = HutchUITests;
282 sourceTree = SOURCE_ROOT;
283 };
232284/* End PBXGroup section */
233285
234286/* 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 };
235305 8B4B28D02F6704280045FA19 /* Hutch */ = {
236306 isa = PBXNativeTarget;
237307 buildConfigurationList = 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */;
@@ -277,8 +347,6 @@
277347 8B8182B82F6B73F3000AE049 /* HutchTests */,
278348 );
279349 name = HutchTests;
280 packageProductDependencies = (
281 );
282350 productName = HutchTests;
283351 productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */;
284352 productType = "com.apple.product-type.bundle.unit-test";
@@ -300,8 +368,6 @@
300368 8BE081F52F80000100000001 /* HutchWidgetExtension */,
301369 );
302370 name = HutchWidgetExtension;
303 packageProductDependencies = (
304 );
305371 productName = HutchWidgetExtension;
306372 productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */;
307373 productType = "com.apple.product-type.app-extension";
@@ -322,8 +388,6 @@
322388 8BE083052F81000100000001 /* HutchSafariExtension */,
323389 );
324390 name = HutchSafariExtension;
325 packageProductDependencies = (
326 );
327391 productName = HutchSafariExtension;
328392 productReference = 8BE083032F81000100000001 /* HutchSafariExtension.appex */;
329393 productType = "com.apple.product-type.app-extension";
@@ -372,11 +436,19 @@
372436 8BE081F72F80000100000001 /* HutchWidgetExtension */,
373437 8BE083092F81000100000001 /* HutchSafariExtension */,
374438 8B8182B62F6B73F3000AE049 /* HutchTests */,
439 55C58E61B9D46807C2524ABE /* HutchUITests */,
375440 );
376441 };
377442/* End PBXProject section */
378443
379444/* Begin PBXResourcesBuildPhase section */
445 6693997E5CA01B62C500224F /* Resources */ = {
446 isa = PBXResourcesBuildPhase;
447 buildActionMask = 2147483647;
448 files = (
449 );
450 runOnlyForDeploymentPostprocessing = 0;
451 };
380452 8B4B28CF2F6704280045FA19 /* Resources */ = {
381453 isa = PBXResourcesBuildPhase;
382454 buildActionMask = 2147483647;
@@ -418,6 +490,14 @@
418490/* End PBXResourcesBuildPhase section */
419491
420492/* 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 };
421501 8B4B28CD2F6704280045FA19 /* Sources */ = {
422502 isa = PBXSourcesBuildPhase;
423503 buildActionMask = 2147483647;
@@ -449,6 +529,12 @@
449529/* End PBXSourcesBuildPhase section */
450530
451531/* Begin PBXTargetDependency section */
532 425D21A6305B6A50B714F42D /* PBXTargetDependency */ = {
533 isa = PBXTargetDependency;
534 name = Hutch;
535 target = 8B4B28D02F6704280045FA19 /* Hutch */;
536 targetProxy = 104E023C02864EA3BCC1B924 /* PBXContainerItemProxy */;
537 };
452538 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */ = {
453539 isa = PBXTargetDependency;
454540 target = 8B4B28D02F6704280045FA19 /* Hutch */;
@@ -467,6 +553,23 @@
467553/* End PBXTargetDependency section */
468554
469555/* 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 };
470573 8B4B28DA2F6704290045FA19 /* Debug */ = {
471574 isa = XCBuildConfiguration;
472575 buildSettings = {
@@ -824,9 +927,34 @@
824927 };
825928 name = Release;
826929 };
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 };
827946/* End XCBuildConfiguration section */
828947
829948/* 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 };
830958 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */ = {
831959 isa = XCConfigurationList;
832960 buildConfigurations = (
Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme +11
@@ -46,6 +46,17 @@
4646 ReferencedContainer = "container:Hutch.xcodeproj">
4747 </BuildableReference>
4848 </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>
4960 </Testables>
5061 </TestAction>
5162 <LaunchAction
HutchTests.xctestplan +25 −19
@@ -1,30 +1,36 @@
11{
2 "configurations" : [
2 "configurations": [
33 {
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": {}
97 }
108 ],
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"
1715 }
1816 },
19 "testTargets" : [
17 "testTargets": [
18 {
19 "parallelizable": true,
20 "target": {
21 "containerPath": "container:Hutch.xcodeproj",
22 "identifier": "8B8182B62F6B73F3000AE049",
23 "name": "HutchTests"
24 }
25 },
2026 {
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"
2632 }
2733 }
2834 ],
29 "version" : 1
35 "version": 1
3036}
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.
232232An unlabelled SF Symbol contributes nothing to a combined label, so hiding them
233233would be churn with no announced difference.
234234
235Still open: **this is not device-verified.** It builds and the suite is green,
236but neither proves a VoiceOver pass. What is verified is that no icon-only
237control is left unlabelled — a static check over the enclosing control's brace
238span, not a guess at line proximity.
235Two 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
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.
239256
240257### SonarCloud backlog — done in code (v3.8.1)
241258
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())