krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: HutchUITests/AccessibilityUITests.swift · raw
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}