From 6aff7b80d50a8e80ff279d38a0ae6ecf463a6d20 Mon Sep 17 00:00:00 2001 From: Wesley Keetch Date: Mon, 7 Sep 2026 23:02:20 -0400 Subject: [PATCH 1/2] test(ui): add accessibility audit and visual QA sweeps The project had no automated accessibility coverage at all, and performAccessibilityAudit was never used anywhere. AccessibilityAuditUITests runs XCTest's auditor over Dashboard, Reminders, Weather and Settings, then repeats the sweep pinned to UICTContentSizeCategoryAccessibilityXXXL. The auditor catches what a normal UI test cannot see: unlabeled controls, labels that duplicate their trait, text clipped at large Dynamic Type, contrast below the WCAG threshold, and hit regions under 44pt. Issues are reported with the screen name and the run continues, so one pass enumerates all of them instead of stopping at the first. VisualQAScreenshotTests captures the four primary screens in light, dark, AX-XXXL and Spanish. Those are the combinations that break Liquid Glass layouts in practice: dark-mode glass contrast, clipping at accessibility sizes, and Spanish string expansion. Both suites disable the ad SDK. Google's banner is a third-party view whose accessibility this project cannot fix, and its network fetch makes the audit nondeterministic. --- SunHatUITests/AccessibilityAuditUITests.swift | 115 ++++++++++++++++++ SunHatUITests/VisualQAScreenshotTests.swift | 97 +++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 SunHatUITests/AccessibilityAuditUITests.swift create mode 100644 SunHatUITests/VisualQAScreenshotTests.swift diff --git a/SunHatUITests/AccessibilityAuditUITests.swift b/SunHatUITests/AccessibilityAuditUITests.swift new file mode 100644 index 0000000..193f859 --- /dev/null +++ b/SunHatUITests/AccessibilityAuditUITests.swift @@ -0,0 +1,115 @@ +// +// AccessibilityAuditUITests.swift +// SunHatUITests +// +// Runs XCTest's built-in accessibility auditor over every primary screen. +// The auditor catches the classes of defect that are invisible in a normal +// UI test but block real users: unlabeled controls, images whose label +// repeats the trait, text that clips at large Dynamic Type sizes, contrast +// below the WCAG threshold, and hit regions under 44pt. +// +// Each screen is audited independently so a failure names the screen it came +// from. The ad SDK is disabled: Google's banner is a third-party view whose +// accessibility this project cannot fix, and its network fetch makes the +// audit nondeterministic. +// + +import XCTest + +final class AccessibilityAuditUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = true + } + + @MainActor + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += [ + "-hasCompletedOnboarding", "YES", + "-hasCreatedFirstReminder", "YES", + "-sunhatDisableAdSDK", + ] + app.launch() + XCTAssertTrue( + app.tabBars.buttons["Home"].waitForExistence(timeout: 20), + "App should reach the main tab bar" + ) + return app + } + + /// Audits the current screen and reports every issue as a test failure + /// tagged with the screen name, so one run enumerates all of them rather + /// than stopping at the first. + @MainActor + private func audit(_ app: XCUIApplication, screen: String) throws { + try app.performAccessibilityAudit { issue in + XCTFail("[\(screen)] \(issue.auditType): \(issue.compactDescription)") + // Already reported above; returning true stops XCTest from + // double-reporting the same issue. + return true + } + } + + @MainActor + private func openTab(_ app: XCUIApplication, _ name: String) { + let tab = app.tabBars.buttons[name] + XCTAssertTrue(tab.waitForExistence(timeout: 10), "\(name) tab should exist") + tab.tap() + } + + // MARK: - Per-screen audits + + @MainActor + func testDashboardIsAccessible() throws { + let app = launchApp() + try audit(app, screen: "Dashboard") + } + + @MainActor + func testRemindersIsAccessible() throws { + let app = launchApp() + openTab(app, "Reminders") + try audit(app, screen: "Reminders") + } + + @MainActor + func testWeatherIsAccessible() throws { + let app = launchApp() + openTab(app, "Weather") + try audit(app, screen: "Weather") + } + + @MainActor + func testSettingsIsAccessible() throws { + let app = launchApp() + openTab(app, "Settings") + try audit(app, screen: "Settings") + } + + // MARK: - Largest accessibility text size + + /// The audit's dynamicTypeSupport check only exercises the size the app is + /// currently running at, so this repeats the sweep pinned to the largest + /// accessibility size — where clipping and truncation actually appear. + @MainActor + func testPrimaryScreensAtLargestAccessibilityTextSize() throws { + let app = XCUIApplication() + app.launchArguments += [ + "-hasCompletedOnboarding", "YES", + "-hasCreatedFirstReminder", "YES", + "-sunhatDisableAdSDK", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL", + ] + app.launch() + XCTAssertTrue( + app.tabBars.buttons["Home"].waitForExistence(timeout: 20), + "App should reach the main tab bar at AX XXXL" + ) + + try audit(app, screen: "Dashboard @ AX-XXXL") + for tab in ["Reminders", "Weather", "Settings"] { + openTab(app, tab) + try audit(app, screen: "\(tab) @ AX-XXXL") + } + } +} diff --git a/SunHatUITests/VisualQAScreenshotTests.swift b/SunHatUITests/VisualQAScreenshotTests.swift new file mode 100644 index 0000000..80ca9d2 --- /dev/null +++ b/SunHatUITests/VisualQAScreenshotTests.swift @@ -0,0 +1,97 @@ +// +// VisualQAScreenshotTests.swift +// SunHatUITests +// +// Captures the four primary screens across the appearance, Dynamic Type and +// localization combinations that break Liquid Glass layouts in practice: +// dark mode (glass contrast), the largest accessibility text size (clipping +// and truncation), and Spanish (string expansion, typically 20-30% longer +// than English). +// +// These tests assert only that each screen renders and stays navigable; the +// screenshots are the deliverable, written to /tmp/sunhat-shots and attached +// to the xcresult for review. +// + +import XCTest + +final class VisualQAScreenshotTests: XCTestCase { + private static let screens = ["Home", "Reminders", "Weather", "Settings"] + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + private func launch( + extraArguments: [String] = [], + appearance: XCUIDevice.Appearance = .light + ) -> XCUIApplication { + XCUIDevice.shared.appearance = appearance + let app = XCUIApplication() + app.launchArguments += [ + "-hasCompletedOnboarding", "YES", + "-hasCreatedFirstReminder", "YES", + "-sunhatDisableAdSDK", + ] + extraArguments + app.launch() + XCTAssertTrue( + app.tabBars.buttons["Home"].waitForExistence(timeout: 20), + "App should reach the main tab bar" + ) + return app + } + + @MainActor + private func captureAllScreens(_ app: XCUIApplication, variant: String) { + for screen in Self.screens { + let tab = app.tabBars.buttons[screen] + XCTAssertTrue(tab.waitForExistence(timeout: 10), "\(screen) tab should exist in \(variant)") + tab.tap() + // Let the tab's content settle before the shot; the glass tab bar + // animates its selection and the dashboard loads asynchronously. + _ = app.staticTexts.firstMatch.waitForExistence(timeout: 5) + save(app.screenshot(), name: "\(variant)-\(screen.lowercased())") + } + } + + @MainActor + func testLightModeDefaultType() throws { + captureAllScreens(launch(appearance: .light), variant: "light-default") + } + + @MainActor + func testDarkModeDefaultType() throws { + captureAllScreens(launch(appearance: .dark), variant: "dark-default") + } + + @MainActor + func testLightModeLargestAccessibilityType() throws { + let app = launch( + extraArguments: ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"], + appearance: .light + ) + captureAllScreens(app, variant: "light-ax-xxxl") + } + + @MainActor + func testSpanishLocalization() throws { + let app = launch( + extraArguments: ["-AppleLanguages", "(es)", "-AppleLocale", "es_ES"], + appearance: .light + ) + captureAllScreens(app, variant: "spanish-default") + } + + @MainActor + private func save(_ screenshot: XCUIScreenshot, name: String) { + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + + let directory = URL(fileURLWithPath: "/tmp/sunhat-shots", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try? screenshot.pngRepresentation.write(to: directory.appendingPathComponent("\(name).png")) + } +} From 27dc1816008dbd452bc83f53d6b0c72ff834ffa1 Mon Sep 17 00:00:00 2001 From: Wesley Keetch Date: Tue, 8 Sep 2026 17:34:03 -0400 Subject: [PATCH 2/2] test(ui): report the failing element in accessibility audit failures The audit type and compactDescription name what failed (contrast, clipped text) but not which control, so a real finding couldn't be traced back to the view that needs fixing. Added issue.element to the failure message. --- SunHatUITests/AccessibilityAuditUITests.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/SunHatUITests/AccessibilityAuditUITests.swift b/SunHatUITests/AccessibilityAuditUITests.swift index 193f859..8753527 100644 --- a/SunHatUITests/AccessibilityAuditUITests.swift +++ b/SunHatUITests/AccessibilityAuditUITests.swift @@ -43,7 +43,12 @@ final class AccessibilityAuditUITests: XCTestCase { @MainActor private func audit(_ app: XCUIApplication, screen: String) throws { try app.performAccessibilityAudit { issue in - XCTFail("[\(screen)] \(issue.auditType): \(issue.compactDescription)") + // element carries the specific control the issue was raised + // against, which the audit type and compactDescription alone + // don't name — without it a contrast or clipping failure can't + // be traced back to the view that needs fixing. + let element = issue.element?.debugDescription ?? "no element" + XCTFail("[\(screen)] \(issue.compactDescription)\n element: \(element)") // Already reported above; returning true stops XCTest from // double-reporting the same issue. return true