TeraAccessibilityUITests.swift (10366B)
1 import XCTest 2 3 @MainActor 4 final class TeraAccessibilityUITests: XCTestCase { 5 private var auditFindings: [String] = [] 6 7 override func tearDown() async throws { 8 continueAfterFailure = true 9 XCTAssertTrue(auditFindings.isEmpty, "Unresolved accessibility findings:\n" + auditFindings.joined(separator: "\n")) 10 auditFindings.removeAll() 11 try await super.tearDown() 12 } 13 14 func testFormUpdateAtLargestTextWithReduceMotionAndLocalSave() throws { 15 try qualifyForm("Update") 16 } 17 18 func testFormPhotoUpdateAtLargestTextWithReduceMotionAndLocalSave() throws { 19 try qualifyForm("Photo update") 20 } 21 22 func testFormAskAtLargestTextWithReduceMotionAndLocalSave() throws { 23 try qualifyForm("Ask") 24 } 25 26 func testFormEventAtLargestTextWithReduceMotionAndLocalSave() throws { 27 try qualifyForm("Event") 28 } 29 30 func testFormFoodAvailabilityAtLargestTextWithReduceMotionAndLocalSave() throws { 31 try qualifyForm("Food availability") 32 } 33 34 func testUnconfirmedSaveStatusAtLargestText() throws { 35 let app = launch(scenario: "save-error", form: "Photo update") 36 XCTAssertTrue(app.tabBars.buttons["Add"].waitForExistence(timeout: 15)) 37 app.tabBars.buttons["Add"].tap() 38 let prepare = app.buttons["Prepare unconfirmed Save"] 39 XCTAssertTrue(prepare.waitForExistence(timeout: 5)) 40 prepare.tap() 41 XCTAssertTrue(prepare.waitForNonExistence(timeout: 5)) 42 let error = app.staticTexts["The Add operation could not be completed."] 43 XCTAssertEqual(error.label, "The Add operation could not be completed.") 44 let state = app.descendants(matching: .any)["tera.add.save-state"] 45 XCTAssertTrue(state.waitForExistence(timeout: 5)) 46 XCTAssertEqual(state.label, "Changes are not confirmed saved. Use Save draft to retry.") 47 scroll(app, to: state) 48 try audit(app) 49 } 50 51 func testActualPhotoDescriptionRemainsEditableAcrossSaveFailureAndRecovery() throws { 52 let app = launch(form: "Photo update") 53 XCTAssertTrue(app.tabBars.buttons["Add"].waitForExistence(timeout: 15)) 54 app.tabBars.buttons["Add"].tap() 55 let library = app.buttons["Photo Library"] 56 scroll(app, to: library) 57 XCTAssertTrue(library.isHittable) 58 library.tap() 59 let description = app.descendants(matching: .any)["Describe this photo"] 60 scroll(app, to: description, up: false) 61 XCTAssertTrue(description.isHittable) 62 description.tap() 63 description.typeText("Carrots arranged in a wooden crate") 64 app.buttons["Done"].tap() 65 XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5)) 66 XCTAssertEqual(description.value as? String, "Carrots arranged in a wooden crate") 67 try audit(app) 68 let save = app.buttons["Save draft"] 69 scroll(app, to: save) 70 save.tap() 71 let failed = app.descendants(matching: .any)["tera.add.save-state"] 72 XCTAssertTrue(failed.waitForExistence(timeout: 5)) 73 XCTAssertTrue(failed.isHittable, "Explicit Save must reveal its acknowledgment") 74 scroll(app, to: failed, up: false) 75 XCTAssertEqual(failed.label, "Changes are not confirmed saved. Use Save draft to retry.") 76 try audit(app) 77 scroll(app, to: description) 78 XCTAssertEqual(description.value as? String, "Carrots arranged in a wooden crate") 79 let remove = app.buttons["Remove photo"] 80 scroll(app, to: remove) 81 remove.tap() 82 scroll(app, to: save) 83 save.tap() 84 let saved = app.descendants(matching: .any)["tera.add.save-state"] 85 XCTAssertTrue(saved.waitForExistence(timeout: 5)) 86 XCTAssertTrue(saved.isHittable, "Explicit Save must reveal its acknowledgment") 87 scroll(app, to: saved, up: false) 88 XCTAssertEqual(saved.label, "Changes saved on this device.") 89 } 90 91 func testRuntimeFailureActionIsReachableAtLargestText() throws { 92 let app = launch(scenario: "failure") 93 let retry = app.buttons["Retry"] 94 XCTAssertTrue(retry.waitForExistence(timeout: 5)) 95 try traverse(app, until: retry, auditing: true) 96 XCTAssertGreaterThanOrEqual(retry.frame.height, 44) 97 retry.tap() 98 XCTAssertTrue(app.staticTexts["Local state checked"].exists) 99 } 100 101 func testSupportSettingsAndDiagnosticsActionRemainReachableAtLargestText() throws { 102 let app = launch() 103 XCTAssertTrue(app.tabBars.buttons["Today"].waitForExistence(timeout: 15)) 104 app.tabBars.buttons["Today"].tap() 105 app.buttons["Account"].tap() 106 let settings = app.buttons["Settings"] 107 scroll(app, to: settings) 108 settings.tap() 109 let diagnostics = app.buttons["Prepare diagnostics export"] 110 scroll(app, to: diagnostics) 111 XCTAssertGreaterThanOrEqual(diagnostics.frame.height, 44) 112 try audit(app) 113 auditFindings += try TeraAccessibilityNavigation.qualifySupportSettings(app, test: self) 114 let back = app.navigationBars.buttons["Me"] 115 XCTAssertTrue(back.waitForExistence(timeout: 5)) 116 back.tap() 117 XCTAssertTrue(app.buttons["Done"].waitForExistence(timeout: 5)) 118 app.buttons["Done"].tap() 119 XCTAssertTrue(app.tabBars.buttons["Today"].waitForExistence(timeout: 5)) 120 } 121 122 func testSecureIdentityImportScalesAndCanBeDismissedWithoutSubmitting() throws { 123 let app = launch(scenario: "identity") 124 let open = app.buttons["Import identity"] 125 XCTAssertTrue(open.waitForExistence(timeout: 5)) 126 try traverse(app, until: open, auditing: true) 127 open.tap() 128 for instruction in ["Enter an nsec or 64-character secret key.", 129 "It is transferred directly to Apple custody.", 130 "Secret input is never stored in view state."] 131 { 132 let text = app.staticTexts.matching(NSPredicate(format: "label == %@", instruction)).firstMatch 133 scroll(app, to: text) 134 try audit(app) 135 } 136 let field = app.secureTextFields["Secret identity key"] 137 scroll(app, to: field) 138 XCTAssertTrue(field.exists) 139 XCTAssertTrue(field.isHittable) 140 XCTAssertGreaterThanOrEqual(field.frame.height, 44) 141 try audit(app) 142 let submit = app.buttons["Import securely"] 143 scroll(app, to: submit) 144 XCTAssertTrue(submit.isHittable) 145 XCTAssertGreaterThanOrEqual(submit.frame.height, 44) 146 scroll(app, to: field, up: false) 147 field.tap() 148 field.typeText("invalid-key\n") 149 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) 150 XCTAssertTrue(app.staticTexts["Enter a valid Nostr secret key."].waitForExistence(timeout: 5)) 151 app.buttons["Cancel"].tap() 152 XCTAssertTrue(open.waitForExistence(timeout: 5)) 153 } 154 } 155 156 private extension TeraAccessibilityUITests { 157 func qualifyForm(_ type: String) throws { 158 let app = launch(form: type) 159 XCTAssertTrue(app.tabBars.buttons["Add"].waitForExistence(timeout: 15), app.debugDescription) 160 if type == "Update" { 161 app.tabBars.buttons["Today"].tap() 162 try audit(app, dynamicType: true) 163 } 164 app.tabBars.buttons["Add"].tap() 165 verifyEditingFocus(app, type: type) 166 try audit(app) 167 let save = app.buttons["Save draft"] 168 let disclosure = app.staticTexts["Submit saves an immutable local snapshot first."] 169 scroll(app, to: disclosure) 170 try audit(app) 171 // Audits may move a lazy form back to its earlier rows. Seek the action 172 // forward first; the bounded navigator reverses at an observed edge. 173 scroll(app, to: save) 174 XCTAssertTrue(save.isEnabled) 175 XCTAssertGreaterThanOrEqual(save.frame.height, 44) 176 save.tap() 177 let saved = app.descendants(matching: .any)["tera.add.save-state"] 178 XCTAssertTrue(saved.waitForExistence(timeout: 5)) 179 XCTAssertTrue(saved.isHittable, "Explicit Save must reveal its acknowledgment") 180 scroll(app, to: saved, up: false) 181 XCTAssertEqual(saved.label, "Changes saved on this device.") 182 XCTAssertTrue(saved.isHittable) 183 try audit(app) 184 app.terminate() 185 } 186 187 func launch(scenario: String = "forms", form: String = "Update") -> XCUIApplication { 188 continueAfterFailure = false 189 _ = TeraAccessibilitySettings.setSystemReduceMotion(true, restoring: self) 190 let app = XCUIApplication() 191 app.launchEnvironment["TERA_IOS_UI_TEST_SHELL"] = "1" 192 app.launchEnvironment["TERA_IOS_UI_TEST_ACCESSIBILITY"] = scenario 193 app.launchEnvironment["TERA_IOS_UI_TEST_FORM"] = form 194 app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US", 195 "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] 196 app.launch() 197 let root = app.otherElements["tera.test.accessibility.root"] 198 XCTAssertTrue(root.waitForExistence(timeout: 5)) 199 let environment = XCTAttachment(string: "Observed presentation environment: \(root.value ?? "missing")") 200 environment.lifetime = .keepAlways 201 add(environment) 202 XCTAssertEqual(root.value as? String, "text=accessibility5; reduceMotion=true") 203 return app 204 } 205 206 func verifyEditingFocus(_ app: XCUIApplication, type: String) { 207 let labels = ["Update": "What’s happening locally?", "Photo update": "What should neighbors know?", 208 "Ask": "What do you need or want to know?", "Event": "Event details (optional)", 209 "Food availability": "Details"] 210 let content = app.textViews[labels[type]!] 211 scroll(app, to: content) 212 XCTAssertEqual(content.label, labels[type]) 213 XCTAssertGreaterThanOrEqual(content.frame.height, 44) 214 content.tap() 215 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) 216 content.typeText("Seedlings available nearby.") 217 XCTAssertEqual(content.value as? String, "Seedlings available nearby.") 218 app.buttons["Done"].tap() 219 XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5)) 220 let traversal = XCTAttachment(string: app.debugDescription) 221 traversal.name = "\(type) editing accessibility tree" 222 traversal.lifetime = .keepAlways 223 add(traversal) 224 } 225 226 func audit(_ app: XCUIApplication, dynamicType: Bool = false) throws { 227 auditFindings += try TeraAccessibilityAudit(test: self).run(app, dynamicType: dynamicType) 228 } 229 230 func traverse(_ app: XCUIApplication, until element: XCUIElement, auditing: Bool) throws { 231 scroll(app, to: element) 232 if auditing { 233 try audit(app) 234 // Audits can move accessibility focus and scroll position. Restore the 235 // requested action before interacting with it. 236 scroll(app, to: element) 237 } 238 } 239 240 func scroll(_ app: XCUIApplication, to element: XCUIElement, up: Bool = true) { 241 TeraAccessibilityNavigation.scroll(app, to: element, up: up, test: self) 242 } 243 }