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}