TeraRemoteQualificationUITests.swift (70417B)
1 import CryptoKit 2 import XCTest 3 4 final class TeraRemoteQualificationUITests: XCTestCase { 5 private static let receiptName = "radroots-remote-qualification-bootstrap.json" 6 7 @MainActor 8 func testBootstrapIdentityWithoutInteractiveAuthentication() throws { 9 let configuration = try QualificationConfiguration.environment() 10 let app = launchToRoot(configuration) 11 let publicKey = try readPublicKey(app) 12 try writeBootstrapReceipt(configuration: configuration, publicKey: publicKey) 13 14 XCUIDevice.shared.press(.home) 15 app.activate() 16 XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10)) 17 18 app.terminate() 19 app.launch() 20 reachRoot(app) 21 XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20)) 22 } 23 24 @MainActor 25 func testStrictPublicEndpointRejectsPrivateAddress() throws { 26 let environment = try QualificationConfiguration.environment() 27 let configuration = QualificationConfiguration( 28 runID: environment.runID, 29 relayURLs: [], 30 blossomOrigins: ["https://127.0.0.1"] 31 ) 32 let app = XCUIApplication() 33 app.launchEnvironment = configuration.launchEnvironment 34 app.launch() 35 36 let failureCode = app.descendants(matching: .any)["radroots.runtime.failure_code"] 37 for _ in 0 ..< 8 where !failureCode.exists { 38 for identifier in [ 39 "radroots.identity.create", 40 "radroots.identity.unlock", 41 "radroots.configuration.reconfigure", 42 "radroots.identity.recover", 43 ] { 44 let action = app.descendants(matching: .any)[identifier] 45 if action.exists, action.isHittable { 46 action.tap() 47 break 48 } 49 } 50 _ = failureCode.waitForExistence(timeout: 4) 51 } 52 XCTAssertEqual( 53 failureCode.label, 54 "Error code runtime_failed", 55 "A private Blossom endpoint did not expose the typed fail-closed result; " 56 + "failure_code.label=\(failureCode.exists ? failureCode.label : "missing")" 57 ) 58 XCTAssertFalse(app.tabBars.firstMatch.exists) 59 } 60 61 @MainActor 62 func testLocalSocialFiveFlowScenario() throws { 63 let configuration = try QualificationConfiguration.environment() 64 let control = try XCTUnwrap(configuration.fixtureControl) 65 try? FileManager.default.removeItem(atPath: control) 66 var app = launchToRoot(configuration) 67 let marker = "\(configuration.runID)-photo" 68 69 guard preparePhotoUpdate(app, marker: marker) != nil else { return } 70 let save = app.buttons["radroots.add.save"] 71 scrollTo(app, element: save) 72 guard save.waitForExistence(timeout: 10), 73 waitUntilHittable(save, timeout: 10), 74 isUnobscured(save, above: app.tabBars.firstMatch) 75 else { 76 return XCTFail("The prepared Photo update could not be saved through the visible UI") 77 } 78 let addRoot = app.descendants(matching: .any)["radroots.add.root"] 79 save.tap() 80 scrollAddFormToTop(app) 81 let saved = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch 82 guard waitForLabel(saved, label: "Draft saved on this device.", timeout: 60) else { 83 return XCTFail("The visible Add status did not confirm the durable draft save") 84 } 85 let saveSettled = NSPredicate { _, _ in 86 addRoot.value as? String == "Ready" 87 } 88 let settleExpectation = XCTNSPredicateExpectation(predicate: saveSettled, object: app) 89 guard XCTWaiter.wait(for: [settleExpectation], timeout: 60) == .completed else { 90 return XCTFail("The visible Save draft action did not finish durable persistence") 91 } 92 assertSavedPhotoEditing(app) 93 app.terminate() 94 95 XCTAssertTrue(FileManager.default.createFile(atPath: control, contents: Data())) 96 app = launchToRoot(configuration) 97 guard openAdd(app) != nil, openDrafts(app) else { 98 return XCTFail("The persisted media draft was unavailable after relaunch") 99 } 100 let reopen = app.buttons["Reopen"].firstMatch 101 XCTAssertTrue(reopen.waitForExistence(timeout: 20)) 102 reopen.tap() 103 guard let retrySubmit = readySubmit(app), 104 let retryValue = submitAndWait(app, submit: retrySubmit) 105 else { return } 106 XCTAssertTrue( 107 !retryValue.contains("Error code") && finishOriginalPublication(app, submit: retrySubmit), 108 "The preserved media draft did not publish after retry; submit.value=\(retryValue)" 109 ) 110 111 let markers = [ 112 "\(configuration.runID)-update", 113 "\(configuration.runID)-ask", 114 "\(configuration.runID)-event", 115 "\(configuration.runID)-food", 116 ] 117 try publishTextFlow(app, type: "Update", marker: markers[0]) 118 try publishTextFlow(app, type: "Ask", marker: markers[1]) 119 try publishEvent(app, marker: markers[2]) 120 try publishFood(app, marker: markers[3]) 121 122 let expected = [marker] + markers 123 try assertTodayContains(app, markers: expected) 124 app.terminate() 125 app = launchToRoot(configuration) 126 try assertTodayContains(app, markers: expected) 127 128 guard openAdd(app) != nil, openDrafts(app) else { 129 return XCTFail("The durable outbox was unavailable after the final relaunch") 130 } 131 assertDraftOutboxContainsAtLeast(app, count: expected.count) 132 app.buttons["Done"].tap() 133 } 134 135 @MainActor 136 func testLocalSocialAccessibilitySemantics() throws { 137 let configuration = try QualificationConfiguration.environment() 138 _ = TeraAccessibilitySettings.setSystemReduceMotion(true, restoring: self) 139 let app = launchToRoot( 140 configuration, 141 launchArguments: [ 142 "-AppleLanguages", "(en)", 143 "-AppleLocale", "en_US", 144 ] 145 ) 146 147 XCTAssertEqual(app.tabBars.firstMatch.buttons.count, 2) 148 XCTAssertGreaterThan(app.staticTexts["Nothing here yet"].frame.height, 36) 149 XCTAssertGreaterThan( 150 app.staticTexts["Pull to refresh or add the first update to this local network."].frame 151 .height, 152 44 153 ) 154 app.tabBars.buttons["Today"].tap() 155 try performLocalSocialAccessibilityAudit(app) 156 157 for type in ["Update", "Photo update", "Ask", "Event", "Food availability"] { 158 try beginDraft(app, type: type) 159 assertProgressiveDisclosure(app, type: type) 160 scrollTo(app, element: app.buttons["radroots.add.submit"]) 161 try performLocalSocialAccessibilityAudit(app) 162 } 163 } 164 165 @MainActor 166 func testLocalSocialDeterministicPersonas() throws { 167 let environment = try QualificationConfiguration.environment() 168 let control = try XCTUnwrap(environment.fixtureControl) 169 let suite = try loadPersonaSuite() 170 XCTAssertEqual(suite.locale, "en_US") 171 XCTAssertEqual(suite.personas.map(\.alias), ["P01", "P02", "P03", "P04", "P05"]) 172 173 var identityDigests = Set<String>() 174 var observations = [String: PersonaAttemptObservation]() 175 for persona in suite.personas { 176 try activateFixture(persona: persona.alias, at: control) 177 let configuration = environment.forPersona(persona.alias) 178 var networkBefore = try readFixtureNetwork(configuration) 179 var app = launchPersona(configuration) 180 let publicKey = try readPublicKey(app) 181 let identityDigest = SHA256.hash(data: Data(publicKey.utf8)) 182 .map { String(format: "%02x", $0) }.joined() 183 XCTAssertTrue(identityDigests.insert(identityDigest).inserted) 184 185 for attempt in persona.attempts { 186 let interaction: PersonaInteractionObservation 187 switch attempt.expectedFailure { 188 case "validation_recovery": 189 interaction = try publishWithValidationRecovery(app, attempt: attempt) 190 case "transport_retry_relaunch": 191 let retry = try publishWithTransportRetry( 192 app, 193 configuration: configuration, 194 attempt: attempt 195 ) 196 app = retry.app 197 interaction = retry.observation 198 case "none": 199 interaction = try publishPersonaAttempt( 200 app, 201 attempt: attempt, 202 interactionProfile: persona.interactionProfile 203 ) 204 default: 205 throw QualificationError.invalidPersonaFixture 206 } 207 try assertTodayContains(app, markers: [attempt.marker]) 208 let networkAfter = try readFixtureNetwork(configuration) 209 observations[attempt.id] = try PersonaAttemptObservation( 210 interaction: interaction, 211 network: networkAfter.delta(from: networkBefore), 212 todayProjectionVerified: true, 213 retentionVerified: false 214 ) 215 networkBefore = networkAfter 216 } 217 218 let markers = persona.attempts.map(\.marker) 219 app.terminate() 220 app = launchPersona(configuration) 221 try assertTodayContains(app, markers: markers) 222 XCTAssertEqual(try readPublicKey(app), publicKey) 223 for attempt in persona.attempts { 224 let observation = try XCTUnwrap(observations[attempt.id]) 225 try writePersonaAttemptAttachment( 226 configuration: configuration, 227 personaAlias: persona.alias, 228 attempt: attempt, 229 identityDigest: identityDigest, 230 observation: observation.confirmingRetention() 231 ) 232 } 233 app.terminate() 234 } 235 XCTAssertEqual(identityDigests.count, 5) 236 } 237 238 @MainActor 239 func testRemoteBlossomUploadAndRecovery() throws { 240 let configuration = try QualificationConfiguration.environment() 241 let app = launchToRoot(configuration) 242 let marker = "Tera remote qualification \(configuration.runID)" 243 244 guard let submit = preparePhotoUpdate(app, marker: marker), 245 let terminalSubmitValue = submitAndWait(app, submit: submit) 246 else { return } 247 guard !terminalSubmitValue.contains("Error code") else { 248 return XCTFail("The Add submission failed locally; submit.value=\(terminalSubmitValue)") 249 } 250 251 guard openDrafts(app) else { 252 return XCTFail("The Drafts sheet did not open after the upload attempt") 253 } 254 let mediaStatus = app.descendants(matching: .any).matching( 255 NSPredicate(format: "identifier BEGINSWITH 'radroots.add.draft.media_status.'") 256 ).firstMatch 257 guard mediaStatus.waitForExistence(timeout: 20) else { 258 app.buttons["Done"].tap() 259 let evidence = readBlossomFailureEvidence(app) 260 XCTFail("Draft media status was unavailable after upload; \(evidence)") 261 return 262 } 263 let mediaStatusLabel = mediaStatus.label 264 guard mediaStatusLabel.contains("1 of 1 photos verified") else { 265 app.buttons["Done"].tap() 266 let evidence = readBlossomFailureEvidence(app) 267 XCTFail( 268 "Remote Blossom upload was not verified; media=\(mediaStatusLabel); \(evidence)" 269 ) 270 return 271 } 272 app.buttons["Done"].tap() 273 274 XCUIDevice.shared.press(.home) 275 app.activate() 276 XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10)) 277 278 app.terminate() 279 app.launch() 280 reachRoot(app) 281 guard openAdd(app) != nil else { 282 return XCTFail("The Add bottom tab did not recover after relaunch") 283 } 284 app.buttons["radroots.add.drafts"].tap() 285 XCTAssertTrue( 286 app.descendants(matching: .any).matching( 287 NSPredicate(format: "label CONTAINS '1 of 1 photos verified'") 288 ).firstMatch.waitForExistence(timeout: 20) 289 ) 290 app.buttons["Done"].tap() 291 292 if !configuration.relayURLs.isEmpty { 293 app.tabBars.buttons["Today"].tap() 294 let published = app.descendants(matching: .any).matching( 295 NSPredicate(format: "label CONTAINS %@", marker) 296 ).firstMatch 297 XCTAssertTrue(published.waitForExistence(timeout: 30)) 298 } 299 } 300 301 @MainActor 302 func testRemoteBlossomUnavailablePersistsDraft() throws { 303 let configuration = try QualificationConfiguration.environment() 304 let app = launchToRoot(configuration) 305 let marker = "Tera unavailable qualification \(configuration.runID)" 306 307 guard let submit = preparePhotoUpdate(app, marker: marker), 308 let terminalSubmitValue = submitAndWait(app, submit: submit) 309 else { return } 310 XCTAssertTrue( 311 terminalSubmitValue.contains("Error code"), 312 "An unavailable Blossom endpoint did not produce a typed failure; " 313 + "submit.value=\(terminalSubmitValue)" 314 ) 315 XCTAssertTrue(assertUnverifiedDraft(app)) 316 317 app.terminate() 318 app.launch() 319 reachRoot(app) 320 guard openAdd(app) != nil else { 321 return XCTFail("The Add bottom tab did not recover after unavailable relaunch") 322 } 323 XCTAssertTrue(assertUnverifiedDraft(app)) 324 } 325 326 @MainActor 327 func testRemoteBlossomRetriesPersistedDraft() throws { 328 let configuration = try QualificationConfiguration.environment() 329 let app = launchToRoot(configuration) 330 guard openAdd(app) != nil else { 331 return XCTFail("The Add bottom tab did not present the Add surface") 332 } 333 guard openDrafts(app) else { 334 return XCTFail("The Drafts sheet did not open for retry") 335 } 336 let unverified = app.descendants(matching: .any).matching( 337 NSPredicate(format: "label CONTAINS '0 of 1 photos verified'") 338 ).firstMatch 339 XCTAssertTrue(unverified.waitForExistence(timeout: 20)) 340 let reopen = app.buttons["Reopen"].firstMatch 341 XCTAssertTrue(reopen.waitForExistence(timeout: 10)) 342 reopen.tap() 343 344 guard let submit = readySubmit(app), 345 let terminalSubmitValue = submitAndWait(app, submit: submit) 346 else { return } 347 guard !terminalSubmitValue.contains("Error code") else { 348 return XCTFail("The persisted Blossom retry failed; submit.value=\(terminalSubmitValue)") 349 } 350 guard openDrafts(app) else { 351 return XCTFail("The Drafts sheet did not open after retry") 352 } 353 XCTAssertTrue( 354 app.descendants(matching: .any).matching( 355 NSPredicate(format: "label CONTAINS '1 of 1 photos verified'") 356 ).firstMatch.waitForExistence(timeout: 20) 357 ) 358 app.buttons["Done"].tap() 359 360 app.terminate() 361 app.launch() 362 reachRoot(app) 363 XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20)) 364 } 365 366 @MainActor 367 func testLocalCancellationPersistsAfterVerifiedUpload() throws { 368 let configuration = try QualificationConfiguration.environment() 369 let app = launchToRoot(configuration) 370 guard openAdd(app) != nil else { 371 return XCTFail("The Add bottom tab did not present the Add surface") 372 } 373 guard openDrafts(app) else { 374 return XCTFail("The Drafts sheet did not open for cancellation") 375 } 376 377 let cancel = app.buttons["Cancel"].firstMatch 378 XCTAssertTrue(cancel.waitForExistence(timeout: 20)) 379 cancel.tap() 380 let cancelled = app.staticTexts.matching( 381 NSPredicate(format: "label == 'Cancelled'") 382 ).firstMatch 383 XCTAssertTrue(cancelled.waitForExistence(timeout: 20)) 384 app.buttons["Done"].tap() 385 386 app.terminate() 387 app.launch() 388 reachRoot(app) 389 guard openAdd(app) != nil else { 390 return XCTFail("The Add bottom tab did not recover after cancellation") 391 } 392 guard openDrafts(app) else { 393 return XCTFail("The Drafts sheet did not reopen after cancellation") 394 } 395 let persistedCancelled = app.staticTexts.matching( 396 NSPredicate(format: "label == 'Cancelled'") 397 ).firstMatch 398 XCTAssertTrue(persistedCancelled.waitForExistence(timeout: 20)) 399 app.buttons["Done"].tap() 400 } 401 402 @MainActor 403 func testManualDeviceLockAndProtectedDataRecovery() throws { 404 let configuration = try QualificationConfiguration.environment() 405 let app = launchToRoot(configuration) 406 XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20)) 407 408 XCUIDevice.shared.press(.home) 409 Thread.sleep(forTimeInterval: 60) 410 411 for _ in 0 ..< 24 { 412 app.activate() 413 if app.tabBars.firstMatch.waitForExistence(timeout: 5) { 414 return 415 } 416 } 417 XCTFail("Tera did not recover after the physical device was unlocked") 418 } 419 420 @MainActor 421 private func launchToRoot( 422 _ configuration: QualificationConfiguration, 423 launchArguments: [String] = [] 424 ) -> XCUIApplication { 425 let app = XCUIApplication() 426 app.launchEnvironment = configuration.launchEnvironment 427 app.launchArguments = launchArguments 428 app.launch() 429 reachRoot(app) 430 return app 431 } 432 433 @MainActor 434 private func launchPersona(_ configuration: QualificationConfiguration) -> XCUIApplication { 435 launchToRoot( 436 configuration, 437 launchArguments: [ 438 "-AppleLanguages", "(en)", 439 "-AppleLocale", "en_US", 440 "-UIAccessibilityReduceMotionEnabled", "YES", 441 ] 442 ) 443 } 444 445 @MainActor 446 private func performLocalSocialAccessibilityAudit( 447 _ app: XCUIApplication, 448 includeContrast: Bool = true 449 ) throws { 450 let findings = try TeraAccessibilityAudit(test: self).run(app, includeContrast: includeContrast) 451 XCTAssertTrue(findings.isEmpty, "Unresolved accessibility findings:\n" + findings.joined(separator: "\n")) 452 } 453 454 @MainActor 455 private func assertProgressiveDisclosure(_ app: XCUIApplication, type: String) { 456 scrollAddFormToTop(app) 457 defer { scrollAddFormToTop(app) } 458 let expected = expectedAddFields[type] ?? [] 459 for identifier in allAddFields { 460 let element = app.descendants(matching: .any)[identifier] 461 if expected.contains(identifier) { 462 scrollTo(app, element: element) 463 XCTAssertTrue( 464 element.waitForExistence(timeout: 10), 465 "Missing progressive-disclosure field for \(type): \(identifier)" 466 ) 467 XCTAssertEqual( 468 element.label, 469 expectedAddFieldLabel(identifier, type: type), 470 "Unexpected accessibility label: \(identifier)" 471 ) 472 } else { 473 XCTAssertFalse( 474 element.exists, 475 "Unexpected progressive-disclosure field for \(type): \(identifier)" 476 ) 477 } 478 } 479 let expectsMedia = type != "Update" 480 let mediaLibrary = app.buttons["radroots.add.media.library"] 481 let mediaCamera = app.buttons["radroots.add.media.camera"] 482 if expectsMedia { 483 scrollTo(app, element: mediaLibrary) 484 XCTAssertTrue(mediaLibrary.waitForExistence(timeout: 10)) 485 XCTAssertTrue(mediaCamera.waitForExistence(timeout: 10)) 486 XCTAssertEqual(mediaLibrary.label, "Photo Library") 487 XCTAssertEqual(mediaCamera.label, "Camera") 488 } else { 489 XCTAssertFalse(mediaLibrary.exists, "Unexpected media-library disclosure for \(type)") 490 XCTAssertFalse(mediaCamera.exists, "Unexpected camera disclosure for \(type)") 491 } 492 } 493 494 private func expectedAddFieldLabel(_ identifier: String, type: String) -> String { 495 switch identifier { 496 case "radroots.add.content": 497 switch type { 498 case "Update": 499 "What’s happening locally?" 500 case "Photo update": 501 "What should neighbors know?" 502 case "Ask": 503 "What do you need or want to know?" 504 case "Event": 505 "Event details (optional)" 506 case "Food availability": 507 "Details" 508 default: 509 preconditionFailure("Unrecognized Add type: \(type)") 510 } 511 case "radroots.add.title": 512 type == "Food availability" ? "Food" : "Title" 513 case "radroots.add.summary": 514 "Short summary" 515 case "radroots.add.location": 516 type == "Food availability" ? "Public location" : "Public location (optional)" 517 case "radroots.add.event_timing": 518 "When, Specific time" 519 case "radroots.add.event.start": 520 "Starts" 521 case "radroots.add.event.end": 522 "Ends" 523 case "radroots.add.price": 524 "Price" 525 case "radroots.add.currency": 526 "Currency" 527 case "radroots.add.unit": 528 "Unit, Choose a unit" 529 case "radroots.add.quantity": 530 "Quantity available (optional)" 531 default: 532 preconditionFailure("Unrecognized Add field identifier: \(identifier)") 533 } 534 } 535 536 private var allAddFields: [String] { 537 [ 538 "radroots.add.content", 539 "radroots.add.title", 540 "radroots.add.summary", 541 "radroots.add.location", 542 "radroots.add.event_timing", 543 "radroots.add.event.start", 544 "radroots.add.event.end", 545 "radroots.add.price", 546 "radroots.add.currency", 547 "radroots.add.unit", 548 "radroots.add.quantity", 549 ] 550 } 551 552 private var expectedAddFields: [String: Set<String>] { 553 [ 554 "Update": ["radroots.add.content"], 555 "Photo update": ["radroots.add.content"], 556 "Ask": ["radroots.add.content"], 557 "Event": [ 558 "radroots.add.content", 559 "radroots.add.title", 560 "radroots.add.location", 561 "radroots.add.event_timing", 562 "radroots.add.event.start", 563 "radroots.add.event.end", 564 ], 565 "Food availability": [ 566 "radroots.add.content", 567 "radroots.add.title", 568 "radroots.add.summary", 569 "radroots.add.location", 570 "radroots.add.price", 571 "radroots.add.currency", 572 "radroots.add.unit", 573 "radroots.add.quantity", 574 ], 575 ] 576 } 577 578 @MainActor 579 private func reachRoot(_ app: XCUIApplication) { 580 for _ in 0 ..< 6 { 581 if app.tabBars.firstMatch.waitForExistence(timeout: 3) { 582 return 583 } 584 for identifier in [ 585 "radroots.identity.create", 586 "radroots.identity.unlock", 587 "radroots.configuration.reconfigure", 588 "radroots.identity.recover", 589 "radroots.runtime.retry", 590 ] { 591 let action = app.descendants(matching: .any)[identifier] 592 if action.exists, action.isHittable { 593 action.tap() 594 break 595 } 596 } 597 } 598 XCTFail("Tera did not reach the two-tab root without interactive authentication") 599 } 600 601 @MainActor 602 private func readPublicKey(_ app: XCUIApplication) throws -> String { 603 app.tabBars.buttons["Today"].tap() 604 let account = app.descendants(matching: .any)["radroots.support.account"] 605 XCTAssertTrue(account.waitForExistence(timeout: 10)) 606 account.tap() 607 608 let meSheet = app.descendants(matching: .any)["radroots.support.me.sheet"] 609 guard meSheet.waitForExistence(timeout: 10) else { 610 throw QualificationError.missingProductSurface 611 } 612 let publicKey = app.descendants(matching: .any)[ 613 "radroots.settings.identity.public_key" 614 ] 615 guard openSettingsFromMe(app, meSheet: meSheet) else { 616 XCTFail("Settings was unavailable through the visible Me sheet") 617 throw QualificationError.missingProductSurface 618 } 619 guard publicKey.waitForExistence(timeout: 10) else { 620 XCTFail("Settings did not present the native identity public key") 621 throw QualificationError.missingProductSurface 622 } 623 guard let value = publicKey.value as? String, 624 value.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil 625 else { 626 throw QualificationError.invalidPublicKey 627 } 628 let back = app.navigationBars.buttons["Me"].firstMatch 629 XCTAssertTrue(back.waitForExistence(timeout: 10)) 630 back.tap() 631 let done = app.navigationBars.buttons["Done"].firstMatch 632 XCTAssertTrue(done.waitForExistence(timeout: 10)) 633 done.tap() 634 XCTAssertTrue( 635 app.descendants(matching: .any)["radroots.support.me.sheet"] 636 .waitForNonExistence(timeout: 10) 637 ) 638 return value 639 } 640 641 @MainActor 642 private func readBlossomFailureEvidence(_ app: XCUIApplication) -> String { 643 app.tabBars.buttons["Today"].tap() 644 let account = app.descendants(matching: .any)["radroots.support.account"] 645 guard account.waitForExistence(timeout: 10) else { return "settings=unavailable" } 646 account.tap() 647 648 let meSheet = app.descendants(matching: .any)["radroots.support.me.sheet"] 649 guard meSheet.waitForExistence(timeout: 10), 650 openSettingsFromMe(app, meSheet: meSheet) 651 else { return "settings=unavailable" } 652 653 let httpStatus = app.descendants(matching: .any)[ 654 "radroots.settings.blossom.http_status" 655 ] 656 let errorCode = app.descendants(matching: .any)[ 657 "radroots.settings.blossom.error_code" 658 ] 659 let serverErrorCode = app.descendants(matching: .any)[ 660 "radroots.settings.blossom.server_error_code" 661 ] 662 for _ in 0 ..< 6 where !httpStatus.exists || !errorCode.exists || !serverErrorCode.exists { 663 app.swipeUp() 664 } 665 _ = httpStatus.waitForExistence(timeout: 10) 666 _ = errorCode.waitForExistence(timeout: 10) 667 _ = serverErrorCode.waitForExistence(timeout: 10) 668 return "http=\(httpStatus.exists ? httpStatus.label : "missing"), " 669 + "error=\(errorCode.exists ? errorCode.label : "missing"), " 670 + "server=\(serverErrorCode.exists ? serverErrorCode.label : "missing")" 671 } 672 673 @MainActor 674 private func publishTextFlow( 675 _ app: XCUIApplication, 676 type: String, 677 marker: String 678 ) throws { 679 try beginDraft(app, type: type) 680 try enterText(app, identifier: "radroots.add.content", value: marker) 681 try submitSuccessfully(app) 682 } 683 684 @MainActor 685 private func publishEvent(_ app: XCUIApplication, marker: String) throws { 686 try beginDraft(app, type: "Event") 687 try enterText(app, identifier: "radroots.add.title", value: marker) 688 try submitSuccessfully(app) 689 } 690 691 @MainActor 692 private func publishFood(_ app: XCUIApplication, marker: String) throws { 693 try beginDraft(app, type: "Food availability") 694 try enterText(app, identifier: "radroots.add.title", value: marker) 695 try enterText(app, identifier: "radroots.add.summary", value: "Fresh local food") 696 try enterText(app, identifier: "radroots.add.content", value: "Available today") 697 try enterText(app, identifier: "radroots.add.location", value: "Town square") 698 try enterText(app, identifier: "radroots.add.price", value: "3") 699 try enterText(app, identifier: "radroots.add.currency", value: "CAD") 700 let unit = app.descendants(matching: .any)["radroots.add.unit"] 701 scrollTo(app, element: unit) 702 XCTAssertTrue(unit.waitForExistence(timeout: 10)) 703 unit.tap() 704 let pounds = app.buttons["lb"] 705 XCTAssertTrue(pounds.waitForExistence(timeout: 10)) 706 pounds.tap() 707 try submitSuccessfully(app) 708 } 709 710 @MainActor 711 private func publishPersonaAttempt( 712 _ app: XCUIApplication, 713 attempt: PersonaAttempt, 714 interactionProfile: String 715 ) throws -> PersonaInteractionObservation { 716 let type = attempt.flow.uiLabel 717 try beginDraft(app, type: type) 718 var progressiveDisclosure = false 719 var accessibilitySemantics = false 720 if interactionProfile == "novice_progressive_disclosure" { 721 assertProgressiveDisclosure(app, type: type) 722 progressiveDisclosure = true 723 } 724 if interactionProfile == "novice_accessibility_keyboard" { 725 // The dedicated accessibility test owns the exact full contrast lane. 726 // Persona attempts retain every semantic audit plus keyboard/focus use. 727 try performLocalSocialAccessibilityAudit(app, includeContrast: false) 728 accessibilitySemantics = true 729 } 730 try completeOpenDraft(app, flow: attempt.flow, marker: attempt.marker) 731 return PersonaInteractionObservation( 732 validationAttempted: false, 733 validationRejected: false, 734 retryAttempts: 0, 735 relaunches: 0, 736 progressiveDisclosure: progressiveDisclosure, 737 accessibilitySemantics: accessibilitySemantics, 738 keyboardFocus: accessibilitySemantics, 739 visibleActions: true 740 ) 741 } 742 743 @MainActor 744 private func completeOpenDraft( 745 _ app: XCUIApplication, 746 flow: PersonaFlow, 747 marker: String 748 ) throws { 749 switch flow { 750 case .update, .ask: 751 try enterText(app, identifier: "radroots.add.content", value: marker) 752 case .photoUpdate: 753 try enterText(app, identifier: "radroots.add.content", value: marker) 754 let library = app.descendants(matching: .any)["radroots.add.media.library"] 755 scrollTo(app, element: library) 756 XCTAssertTrue(library.waitForExistence(timeout: 10)) 757 XCTAssertTrue(library.isEnabled) 758 library.tap() 759 let prepared = app.descendants(matching: .any)["radroots.add.media.prepared"] 760 scrollTo(app, element: prepared) 761 guard prepared.waitForExistence(timeout: 60) else { 762 XCTFail("The governed prepared Photo update was unavailable through the visible UI") 763 throw QualificationError.missingProductSurface 764 } 765 guard prepared.isHittable else { 766 XCTFail("The governed prepared Photo update was obscured") 767 throw QualificationError.missingProductSurface 768 } 769 try enterPhotoDescription(app) 770 case .event: 771 try enterText(app, identifier: "radroots.add.title", value: marker) 772 case .foodAvailability: 773 try enterText(app, identifier: "radroots.add.title", value: marker) 774 try enterText(app, identifier: "radroots.add.summary", value: "Fresh local food") 775 try enterText(app, identifier: "radroots.add.content", value: "Available today") 776 try enterText(app, identifier: "radroots.add.location", value: "Town square") 777 try enterText(app, identifier: "radroots.add.price", value: "3") 778 try enterText(app, identifier: "radroots.add.currency", value: "CAD") 779 let unit = app.descendants(matching: .any)["radroots.add.unit"] 780 scrollTo(app, element: unit) 781 XCTAssertTrue(unit.waitForExistence(timeout: 10)) 782 unit.tap() 783 let pounds = app.buttons["lb"] 784 XCTAssertTrue(pounds.waitForExistence(timeout: 10)) 785 pounds.tap() 786 } 787 try submitSuccessfully(app) 788 } 789 790 @MainActor 791 private func publishWithValidationRecovery( 792 _ app: XCUIApplication, 793 attempt: PersonaAttempt 794 ) throws -> PersonaInteractionObservation { 795 guard attempt.flow == .event else { 796 throw QualificationError.invalidPersonaFixture 797 } 798 try beginDraft(app, type: attempt.flow.uiLabel) 799 try enterText(app, identifier: "radroots.add.content", value: attempt.marker) 800 guard let submit = readySubmit(app), let value = submitAndWait(app, submit: submit) else { 801 throw QualificationError.missingProductSurface 802 } 803 XCTAssertTrue(value.contains("Error code")) 804 let content = app.descendants(matching: .any)["radroots.add.content"] 805 scrollTo(app, element: content) 806 XCTAssertEqual(content.value as? String, attempt.marker) 807 try enterText(app, identifier: "radroots.add.title", value: attempt.marker) 808 try submitSuccessfully(app) 809 return PersonaInteractionObservation( 810 validationAttempted: true, 811 validationRejected: true, 812 retryAttempts: 0, 813 relaunches: 0, 814 progressiveDisclosure: false, 815 accessibilitySemantics: false, 816 keyboardFocus: false, 817 visibleActions: true 818 ) 819 } 820 821 @MainActor 822 private func publishWithTransportRetry( 823 _ app: XCUIApplication, 824 configuration: QualificationConfiguration, 825 attempt: PersonaAttempt 826 ) throws -> (app: XCUIApplication, observation: PersonaInteractionObservation) { 827 guard attempt.flow == .ask else { 828 throw QualificationError.invalidPersonaFixture 829 } 830 try beginDraft(app, type: attempt.flow.uiLabel) 831 try enterText(app, identifier: "radroots.add.content", value: attempt.marker) 832 guard let submit = readySubmit(app), submitAndWait(app, submit: submit) != nil else { 833 throw QualificationError.missingProductSurface 834 } 835 app.terminate() 836 837 let relaunched = launchPersona(configuration) 838 guard openAdd(relaunched) != nil, openDrafts(relaunched) else { 839 throw QualificationError.missingProductSurface 840 } 841 let retry = relaunched.buttons["Retry"].firstMatch 842 guard retry.waitForExistence(timeout: 20) else { 843 XCTFail("The transport-retry draft did not expose the Retry action") 844 throw QualificationError.missingProductSurface 845 } 846 retry.tap() 847 let retryCompleted = NSPredicate { _, _ in !retry.exists || !retry.isEnabled } 848 let expectation = XCTNSPredicateExpectation(predicate: retryCompleted, object: relaunched) 849 XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 180), .completed) 850 let done = relaunched.buttons["Done"] 851 XCTAssertTrue(done.waitForExistence(timeout: 10)) 852 done.tap() 853 return ( 854 relaunched, 855 PersonaInteractionObservation( 856 validationAttempted: false, 857 validationRejected: false, 858 retryAttempts: 1, 859 relaunches: 1, 860 progressiveDisclosure: false, 861 accessibilitySemantics: false, 862 keyboardFocus: false, 863 visibleActions: true 864 ) 865 ) 866 } 867 868 private func loadPersonaSuite() throws -> PersonaSuite { 869 let bundle = Bundle(for: TeraRemoteQualificationUITests.self) 870 let url = try XCTUnwrap( 871 bundle.url(forResource: "local-social-personas.v1", withExtension: "json") 872 ) 873 let data = try Data(contentsOf: url, options: [.mappedIfSafe]) 874 guard data.count <= 64 * 1024 else { 875 throw QualificationError.invalidPersonaFixture 876 } 877 return try JSONDecoder().decode(PersonaSuite.self, from: data) 878 } 879 880 private func activateFixture(persona: String, at path: String) throws { 881 guard ["P01", "P02", "P03", "P04", "P05"].contains(persona) else { 882 throw QualificationError.invalidPersonaFixture 883 } 884 let data = try JSONSerialization.data( 885 withJSONObject: [ 886 "schema": "tera.ios.local-social.persona-control.v1", 887 "active_persona": persona, 888 "blossom_enabled": true, 889 ], 890 options: [.sortedKeys] 891 ) 892 try data.write(to: URL(fileURLWithPath: path), options: [.atomic]) 893 } 894 895 private func readFixtureNetwork( 896 _ configuration: QualificationConfiguration 897 ) throws -> PersonaFixtureNetworkSnapshot { 898 let path = try XCTUnwrap(configuration.fixtureEvidence) 899 let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) 900 defer { try? handle.close() } 901 let data = try handle.read(upToCount: 64 * 1024 + 1) ?? Data() 902 guard !data.isEmpty, data.count <= 64 * 1024 else { 903 throw QualificationError.invalidFixtureEvidence 904 } 905 let root = try JSONSerialization.jsonObject(with: data) 906 guard let value = root as? [String: Any] else { 907 throw QualificationError.invalidFixtureEvidence 908 } 909 return try PersonaFixtureNetworkSnapshot(value) 910 } 911 912 @MainActor 913 private func beginDraft(_ app: XCUIApplication, type: String) throws { 914 guard openAdd(app) != nil else { 915 XCTFail("The Add bottom tab did not present the real Add store") 916 throw QualificationError.missingProductSurface 917 } 918 let newDraft = app.buttons["radroots.add.new"] 919 guard beginNewDraft(newDraft) else { 920 XCTFail("The New draft action was unavailable") 921 throw QualificationError.missingProductSurface 922 } 923 // The explicit replacement must settle before changing the new form. 924 scrollAddFormToTop(app) 925 let picker = app.descendants(matching: .any)["radroots.add.type"] 926 scrollTo(app, element: picker) 927 guard picker.waitForExistence(timeout: 10), waitUntilHittable(picker, timeout: 10) else { 928 XCTFail("The Add type picker was unavailable") 929 throw QualificationError.missingProductSurface 930 } 931 if picker.value as? String == type { 932 return 933 } 934 picker.tap() 935 let option = app.buttons[type] 936 guard option.waitForExistence(timeout: 10), waitUntilHittable(option, timeout: 10) else { 937 XCTFail("The Add type picker did not present \(type)") 938 throw QualificationError.missingProductSurface 939 } 940 option.tap() 941 guard waitForValue(picker, value: type, timeout: 10) else { 942 XCTFail("The Add type picker did not select \(type)") 943 throw QualificationError.missingProductSurface 944 } 945 } 946 947 @MainActor 948 func enterText( 949 _ app: XCUIApplication, 950 identifier: String, 951 value: String 952 ) throws { 953 let field = app.descendants(matching: .any)[identifier] 954 scrollTo(app, element: field) 955 guard field.waitForExistence(timeout: 10), waitUntilHittable(field, timeout: 10) else { 956 XCTFail("The Add field \(identifier) was unavailable") 957 throw QualificationError.missingProductSurface 958 } 959 guard focusForTextInput(field, timeout: 10) else { 960 XCTFail("The Add field \(identifier) did not accept keyboard focus") 961 throw QualificationError.missingProductSurface 962 } 963 enterExactText(field, value: value) 964 let done = app.buttons["radroots.add.keyboard.done"] 965 if done.waitForExistence(timeout: 5) { 966 done.tap() 967 XCTAssertFalse(app.keyboards.firstMatch.waitForExistence(timeout: 5)) 968 } 969 } 970 971 @MainActor 972 private func submitSuccessfully(_ app: XCUIApplication) throws { 973 guard let submit = readySubmit(app), 974 let value = submitAndWait(app, submit: submit) 975 else { 976 throw QualificationError.missingProductSurface 977 } 978 guard !value.contains("Error code"), finishOriginalPublication(app, submit: submit) else { 979 XCTFail("The local-social flow failed: \(value)") 980 throw QualificationError.productSubmissionFailed 981 } 982 } 983 984 @MainActor 985 func scrollTo(_ app: XCUIApplication, element: XCUIElement) { 986 let root = app.descendants(matching: .any)["radroots.add.root"] 987 let tabBar = app.tabBars.firstMatch 988 for attempt in 0 ..< 16 where !isVisibleInAddViewport( 989 element, 990 root: root, 991 above: tabBar 992 ) { 993 if element.exists { 994 let targetFrame = element.frame 995 let rootFrame = root.frame 996 if targetFrame.midY < rootFrame.midY { 997 root.swipeDown() 998 } else { 999 root.swipeUp() 1000 } 1001 } else if attempt < 8 { 1002 root.swipeUp() 1003 } else { 1004 root.swipeDown() 1005 } 1006 } 1007 } 1008 1009 @MainActor 1010 private func isVisibleInAddViewport( 1011 _ element: XCUIElement, 1012 root: XCUIElement, 1013 above obstruction: XCUIElement 1014 ) -> Bool { 1015 guard element.exists, element.isHittable, root.exists, obstruction.exists else { 1016 return false 1017 } 1018 let frame = element.frame 1019 let rootFrame = root.frame 1020 return frame.height > 0 1021 && frame.minY >= rootFrame.minY 1022 && frame.maxY <= min(rootFrame.maxY, obstruction.frame.minY) 1023 } 1024 1025 @MainActor 1026 private func scrollAddFormToTop(_ app: XCUIApplication) { 1027 let root = app.descendants(matching: .any)["radroots.add.root"] 1028 for _ in 0 ..< 8 { 1029 root.swipeDown() 1030 } 1031 } 1032 1033 @MainActor 1034 private func assertTodayContains(_ app: XCUIApplication, markers: [String]) throws { 1035 app.tabBars.buttons["Today"].tap() 1036 let refresh = app.buttons["radroots.today.refresh"] 1037 guard refresh.waitForExistence(timeout: 10), refresh.isHittable else { 1038 XCTFail("The ordinary Today refresh action was unavailable") 1039 throw QualificationError.missingProductSurface 1040 } 1041 refresh.tap() 1042 let feed = app.descendants(matching: .any)["radroots.today.feed"].firstMatch 1043 guard feed.waitForExistence(timeout: 30) else { 1044 XCTFail("The local relay refresh did not materialize the Today feed") 1045 throw QualificationError.missingProductSurface 1046 } 1047 for marker in markers.reversed() { 1048 for _ in 0 ..< 8 { 1049 feed.swipeDown() 1050 } 1051 let card = app.descendants(matching: .any).matching( 1052 NSPredicate(format: "label CONTAINS %@", marker) 1053 ).firstMatch 1054 for _ in 0 ..< 6 where !card.exists { 1055 feed.swipeUp() 1056 } 1057 guard card.waitForExistence(timeout: 30) else { 1058 XCTFail("Missing Today card \(marker)") 1059 throw QualificationError.missingProductSurface 1060 } 1061 } 1062 } 1063 1064 @MainActor 1065 func readySubmit(_ app: XCUIApplication) -> XCUIElement? { 1066 let submit = app.descendants(matching: .any)["radroots.add.submit"] 1067 let addRoot = app.descendants(matching: .any)["radroots.add.root"] 1068 for _ in 0 ..< 8 { 1069 if isUnobscured(submit, above: app.tabBars.firstMatch) { 1070 break 1071 } 1072 addRoot.swipeUp() 1073 } 1074 guard submit.waitForExistence(timeout: 10), 1075 submit.isEnabled, 1076 waitUntilHittable(submit, timeout: 10), 1077 isUnobscured(submit, above: app.tabBars.firstMatch) 1078 else { 1079 XCTFail( 1080 "The Add submission control did not become unobscured; " 1081 + submissionDiagnostics(app, submit: submit) 1082 ) 1083 return nil 1084 } 1085 return submit 1086 } 1087 1088 @MainActor 1089 func submitAndWait(_ app: XCUIApplication, submit: XCUIElement) -> String? { 1090 let status = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch 1091 let priorStatusLabel = status.exists ? status.label : nil 1092 let priorSubmitValue = submit.value as? String 1093 submit.tap() 1094 guard 1095 waitForWorkToFinish( 1096 app, 1097 submit: submit, 1098 status: status, 1099 priorStatusLabel: priorStatusLabel, 1100 priorSubmitValue: priorSubmitValue 1101 ) 1102 else { 1103 XCTFail( 1104 "The Add submission did not reach a terminal local state; " 1105 + submissionDiagnostics(app, submit: submit) 1106 ) 1107 return nil 1108 } 1109 scrollTo(app, element: submit) 1110 guard submit.waitForExistence(timeout: 10), terminalControlShowsSettledState(submit) else { 1111 XCTFail("The terminal Add submission control was unavailable") 1112 return nil 1113 } 1114 let terminalValue = submit.value as? String ?? "missing" 1115 let statusChanged = status.exists 1116 && !status.label.isEmpty 1117 && status.label != priorStatusLabel 1118 guard statusChanged || terminalValue != priorSubmitValue && terminalValue != "Working" else { 1119 XCTFail( 1120 "The Add submission did not expose changed terminal evidence; " 1121 + submissionDiagnostics(app, submit: submit) 1122 ) 1123 return nil 1124 } 1125 return terminalValue 1126 } 1127 1128 @MainActor 1129 private func assertUnverifiedDraft(_ app: XCUIApplication) -> Bool { 1130 guard openDrafts(app) else { 1131 XCTFail("The Drafts sheet did not open after the unavailable attempt") 1132 return false 1133 } 1134 let selectedMedia = app.descendants(matching: .any)["tera.add.submission.media_status"] 1135 let viewSubmission = app.buttons["View submission"].firstMatch 1136 if !selectedMedia.exists, viewSubmission.exists { 1137 viewSubmission.tap() 1138 } 1139 let mediaStatus = app.descendants(matching: .any).matching( 1140 NSPredicate(format: "label CONTAINS '0 of 1 photos verified'") 1141 ).firstMatch 1142 let exists = mediaStatus.waitForExistence(timeout: 20) 1143 XCTAssertTrue(exists) 1144 if exists { 1145 XCTAssertTrue( 1146 mediaStatus.label == "0 of 1 photos verified" 1147 || mediaStatus.label == "0 of 1 photos verified; 1 possible orphan", 1148 "An unavailable upload did not preserve a bounded retry state; " 1149 + "media_status.label=\(mediaStatus.label)" 1150 ) 1151 } 1152 app.buttons["Done"].tap() 1153 return exists 1154 } 1155 1156 @MainActor 1157 private func isUnobscured(_ element: XCUIElement, above obstruction: XCUIElement) -> Bool { 1158 guard element.exists, element.isEnabled, element.isHittable, obstruction.exists else { 1159 return false 1160 } 1161 let frame = element.frame 1162 return frame.height > 0 1163 && frame.minY >= 0 1164 && frame.maxY <= obstruction.frame.minY 1165 } 1166 1167 @MainActor 1168 private func submissionDiagnostics( 1169 _ app: XCUIApplication, 1170 submit: XCUIElement 1171 ) -> String { 1172 let progress = app.descendants(matching: .any)["radroots.add.progress"] 1173 let status = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch 1174 let statusLabel = status.exists ? status.label : "missing" 1175 guard submit.exists else { 1176 return "submit.exists=false, progress.exists=\(progress.exists), status=\(statusLabel)" 1177 } 1178 let tabBar = app.tabBars.firstMatch 1179 return "submit.exists=\(submit.exists), submit.enabled=\(submit.isEnabled), " 1180 + "submit.hittable=\(submit.isHittable), submit.value=\(String(describing: submit.value)), " 1181 + "submit.frame=\(submit.frame), " 1182 + "tab_bar_top=\(tabBar.exists ? tabBar.frame.minY : -1), " 1183 + "progress.exists=\(progress.exists), status=\(statusLabel)" 1184 } 1185 1186 @MainActor 1187 func openDrafts(_ app: XCUIApplication) -> Bool { 1188 let drafts = app.buttons["radroots.add.drafts"] 1189 guard drafts.waitForExistence(timeout: 10) else { return false } 1190 let sheet = app.descendants(matching: .any)["radroots.add.drafts.sheet"] 1191 for _ in 0 ..< 3 { 1192 drafts.tap() 1193 if sheet.waitForExistence(timeout: 10) { 1194 return true 1195 } 1196 } 1197 return false 1198 } 1199 1200 @MainActor 1201 func waitUntilHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { 1202 let predicate = NSPredicate(format: "exists == true AND hittable == true") 1203 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) 1204 return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed 1205 } 1206 1207 @MainActor 1208 private func openSettingsFromMe( 1209 _ app: XCUIApplication, 1210 meSheet: XCUIElement 1211 ) -> Bool { 1212 let meList = meSheet.descendants(matching: .collectionView).firstMatch 1213 guard meList.waitForExistence(timeout: 10) else { return false } 1214 let settings = app.descendants(matching: .any)["radroots.support.settings"] 1215 for _ in 0 ..< 8 { 1216 if settings.exists, 1217 settings.isHittable, 1218 settings.frame.minY >= meSheet.frame.minY, 1219 settings.frame.maxY <= meSheet.frame.maxY 1220 { 1221 settings.tap() 1222 return app.descendants(matching: .any)["radroots.support.settings.view"] 1223 .waitForExistence(timeout: 20) 1224 } 1225 meList.swipeUp() 1226 } 1227 return false 1228 } 1229 1230 @MainActor 1231 private func focusForTextInput(_ element: XCUIElement, timeout: TimeInterval) -> Bool { 1232 let focused = NSPredicate(format: "hasKeyboardFocus == true") 1233 for _ in 0 ..< 3 { 1234 element.tap() 1235 let expectation = XCTNSPredicateExpectation(predicate: focused, object: element) 1236 if XCTWaiter.wait(for: [expectation], timeout: timeout / 3) == .completed { 1237 return true 1238 } 1239 } 1240 return false 1241 } 1242 1243 @MainActor 1244 func waitForValue( 1245 _ element: XCUIElement, 1246 value: String, 1247 timeout: TimeInterval 1248 ) -> Bool { 1249 let predicate = NSPredicate { object, _ in 1250 guard let object = object as? XCUIElement else { return false } 1251 return object.exists && object.value as? String == value 1252 } 1253 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) 1254 return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed 1255 } 1256 1257 @MainActor 1258 private func waitForLabel( 1259 _ element: XCUIElement, 1260 label: String, 1261 timeout: TimeInterval 1262 ) -> Bool { 1263 let predicate = NSPredicate { object, _ in 1264 guard let object = object as? XCUIElement else { return false } 1265 return object.exists && object.label == label 1266 } 1267 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) 1268 return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed 1269 } 1270 1271 private func writeBootstrapReceipt( 1272 configuration: QualificationConfiguration, 1273 publicKey: String 1274 ) throws { 1275 let receipt = BootstrapReceipt( 1276 schema: "tera-ios-remote-qualification-bootstrap-v1", 1277 schemaVersion: 1, 1278 runID: configuration.runID, 1279 publicKey: publicKey, 1280 relayURLs: configuration.relayURLs, 1281 blossomOrigins: configuration.blossomOrigins, 1282 privateKeyPresent: false, 1283 interactiveAuthenticationRequired: false 1284 ) 1285 let encoder = JSONEncoder() 1286 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 1287 let data = try encoder.encode(receipt) 1288 let documents = try XCTUnwrap( 1289 FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first 1290 ) 1291 try data.write( 1292 to: documents.appendingPathComponent(Self.receiptName), 1293 options: .atomic 1294 ) 1295 let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") 1296 attachment.name = Self.receiptName 1297 attachment.lifetime = .keepAlways 1298 add(attachment) 1299 } 1300 1301 private func writePersonaAttemptAttachment( 1302 configuration: QualificationConfiguration, 1303 personaAlias: String, 1304 attempt: PersonaAttempt, 1305 identityDigest: String, 1306 observation: PersonaAttemptObservation 1307 ) throws { 1308 let binding = try XCTUnwrap(configuration.evidenceBinding) 1309 let evidence = PersonaAttemptEvidence( 1310 schema: "tera.ios.local-social.persona-attempt-evidence.v1", 1311 schemaVersion: 1, 1312 testInvocation: .init( 1313 target: "TeraUITests", 1314 identifier: "TeraUITests/testLocalSocialDeterministicPersonas", 1315 action: "test", 1316 configuration: "Debug" 1317 ), 1318 source: .init(commit: binding.sourceCommit, tree: binding.sourceTree), 1319 appBuildSHA256: binding.appBuildSHA256, 1320 simulator: .init( 1321 udid: binding.simulatorID, 1322 os: Self.currentIOSVersion, 1323 architecture: "arm64" 1324 ), 1325 runID: configuration.qualificationRunID, 1326 personaRunID: configuration.runID, 1327 personaAlias: personaAlias, 1328 attemptID: attempt.id, 1329 attemptOrder: attempt.order, 1330 flow: attempt.flow.rawValue, 1331 expectedFailure: attempt.expectedFailure, 1332 publicIdentitySHA256: identityDigest, 1333 endpointPolicySHA256: Self.endpointPolicyDigest(configuration), 1334 uiObservation: observation.ui, 1335 networkObservation: observation.network, 1336 accessibility: .init( 1337 locale: "en_US", 1338 contentSize: "accessibility-extra-extra-extra-large", 1339 reduceMotion: true, 1340 progressiveDisclosure: observation.interaction.progressiveDisclosure, 1341 labelsValuesTraits: observation.interaction.accessibilitySemantics, 1342 keyboardFocus: observation.interaction.keyboardFocus, 1343 visibleActions: observation.interaction.visibleActions, 1344 voiceoverUserObserved: false 1345 ), 1346 artifactDigests: [] 1347 ) 1348 let encoder = JSONEncoder() 1349 encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] 1350 let data = try encoder.encode(evidence) 1351 XCTAssertLessThanOrEqual(data.count, 64 * 1024) 1352 let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") 1353 attachment.name = "radroots-local-social-\(attempt.id).json" 1354 attachment.lifetime = .keepAlways 1355 add(attachment) 1356 } 1357 1358 private static var currentIOSVersion: String { 1359 let version = ProcessInfo.processInfo.operatingSystemVersion 1360 return "iOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" 1361 } 1362 1363 private static func endpointPolicyDigest( 1364 _ configuration: QualificationConfiguration 1365 ) -> String { 1366 var payload = Data("radroots.ios.local-social.endpoint-policy.v1\0".utf8) 1367 for value in (configuration.relayURLs + configuration.blossomOrigins).sorted() { 1368 let bytes = Data(value.utf8) 1369 var length = UInt64(bytes.count).bigEndian 1370 withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) } 1371 payload.append(bytes) 1372 } 1373 return SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined() 1374 } 1375 } 1376 1377 private struct QualificationEndpointPolicy { 1378 private enum Role { 1379 case relay 1380 case blossom 1381 } 1382 1383 init(relayURLs: [String], blossomOrigins: [String], profile: String) throws { 1384 guard !relayURLs.isEmpty, 1385 Set(relayURLs).count == relayURLs.count, 1386 blossomOrigins.count == 1 1387 else { 1388 throw QualificationError.invalidEndpointPolicy 1389 } 1390 if profile == "simulator" { 1391 guard 1392 relayURLs.allSatisfy({ Self.isEndpoint($0, role: .relay, loopbackOnly: true) }), 1393 blossomOrigins.allSatisfy({ 1394 Self.isEndpoint($0, role: .blossom, loopbackOnly: true) 1395 }) 1396 else { 1397 throw QualificationError.invalidEndpointPolicy 1398 } 1399 } else { 1400 guard 1401 relayURLs.allSatisfy({ Self.isEndpoint($0, role: .relay, loopbackOnly: false) }), 1402 blossomOrigins.allSatisfy({ 1403 Self.isEndpoint($0, role: .blossom, loopbackOnly: false) 1404 }) 1405 else { 1406 throw QualificationError.invalidEndpointPolicy 1407 } 1408 } 1409 } 1410 1411 private static func isEndpoint( 1412 _ raw: String, 1413 role: Role, 1414 loopbackOnly: Bool 1415 ) -> Bool { 1416 guard let value = URLComponents(string: raw), 1417 value.user == nil, 1418 value.password == nil, 1419 value.query == nil, 1420 value.fragment == nil, 1421 let scheme = value.scheme?.lowercased(), 1422 let host = value.host?.lowercased(), 1423 !host.isEmpty 1424 else { return false } 1425 let expectedScheme = switch (role, loopbackOnly) { 1426 case (.relay, true): "ws" 1427 case (.blossom, true): "http" 1428 case (.relay, false): "wss" 1429 case (.blossom, false): "https" 1430 } 1431 if loopbackOnly { 1432 return scheme == expectedScheme 1433 && host == "127.0.0.1" 1434 && value.port.map { 1 ... 65535 ~= $0 } == true 1435 && (value.path.isEmpty || value.path == "/") 1436 } 1437 return scheme == expectedScheme 1438 && host != "127.0.0.1" 1439 && host != "::1" 1440 && host != "localhost" 1441 } 1442 } 1443 1444 private struct QualificationConfiguration { 1445 static let enabledKey = "TERA_IOS_UI_TEST_REMOTE" 1446 static let runIDKey = "TERA_IOS_UI_TEST_RUN_ID" 1447 static let relayURLsKey = "TERA_IOS_UI_TEST_NOSTR_RELAY_URLS" 1448 static let blossomOriginsKey = "TERA_IOS_UI_TEST_BLOSSOM_ORIGINS" 1449 static let fixtureControlKey = "TERA_IOS_UI_TEST_FIXTURE_CONTROL" 1450 static let fixtureEvidenceKey = "TERA_IOS_UI_TEST_FIXTURE_EVIDENCE" 1451 static let mediaRelativePathKey = "TERA_IOS_UI_TEST_MEDIA_RELATIVE_PATH" 1452 static let networkProfileKey = "TERA_IOS_UI_TEST_NETWORK_PROFILE" 1453 static let sourceCommitKey = "TERA_IOS_UI_TEST_SOURCE_COMMIT" 1454 static let sourceTreeKey = "TERA_IOS_UI_TEST_SOURCE_TREE" 1455 static let appBuildSHA256Key = "TERA_IOS_UI_TEST_APP_BUILD_SHA256" 1456 static let simulatorIDKey = "TERA_IOS_UI_TEST_SIMULATOR_ID" 1457 1458 let runID: String 1459 let qualificationRunID: String 1460 let relayURLs: [String] 1461 let blossomOrigins: [String] 1462 let fixtureControl: String? 1463 let fixtureEvidence: String? 1464 let networkProfile: String 1465 let evidenceBinding: PersonaEvidenceBinding? 1466 1467 init( 1468 runID: String, 1469 relayURLs: [String], 1470 blossomOrigins: [String], 1471 fixtureControl: String? = nil, 1472 fixtureEvidence: String? = nil, 1473 networkProfile: String = "public", 1474 qualificationRunID: String? = nil, 1475 evidenceBinding: PersonaEvidenceBinding? = nil 1476 ) { 1477 self.runID = runID 1478 self.qualificationRunID = qualificationRunID ?? runID 1479 self.relayURLs = relayURLs 1480 self.blossomOrigins = blossomOrigins 1481 self.fixtureControl = fixtureControl 1482 self.fixtureEvidence = fixtureEvidence 1483 self.networkProfile = networkProfile 1484 self.evidenceBinding = evidenceBinding 1485 } 1486 1487 var launchEnvironment: [String: String] { 1488 [ 1489 Self.enabledKey: "1", 1490 Self.runIDKey: runID, 1491 Self.relayURLsKey: relayURLs.joined(separator: ","), 1492 Self.blossomOriginsKey: blossomOrigins.joined(separator: ","), 1493 Self.mediaRelativePathKey: "qualification/input.png", 1494 Self.networkProfileKey: networkProfile, 1495 "RUST_BACKTRACE": "1", 1496 ] 1497 } 1498 1499 func forPersona(_ alias: String) -> Self { 1500 let digest = SHA256.hash( 1501 data: Data("radroots.ios.local-social.persona-run.v1\0\(runID)\0\(alias)".utf8) 1502 ) 1503 let suffix = digest.prefix(12).map { String(format: "%02x", $0) }.joined() 1504 return Self( 1505 runID: "persona-\(alias.lowercased())-\(suffix)", 1506 relayURLs: relayURLs, 1507 blossomOrigins: blossomOrigins, 1508 fixtureControl: fixtureControl, 1509 fixtureEvidence: fixtureEvidence, 1510 networkProfile: networkProfile, 1511 qualificationRunID: qualificationRunID, 1512 evidenceBinding: evidenceBinding 1513 ) 1514 } 1515 1516 static func environment( 1517 _ values: [String: String] = ProcessInfo.processInfo.environment 1518 ) throws -> Self { 1519 let bundle = Bundle(for: TeraRemoteQualificationUITests.self) 1520 let runIDValue = 1521 values[runIDKey] 1522 ?? bundle.object(forInfoDictionaryKey: runIDKey) as? String 1523 let relayValue = 1524 values[relayURLsKey] 1525 ?? bundle.object(forInfoDictionaryKey: relayURLsKey) as? String 1526 let blossomValue = 1527 values[blossomOriginsKey] 1528 ?? bundle.object(forInfoDictionaryKey: blossomOriginsKey) as? String 1529 let fixtureControl = 1530 values[fixtureControlKey] 1531 ?? bundle.object(forInfoDictionaryKey: fixtureControlKey) as? String 1532 let fixtureEvidence = 1533 values[fixtureEvidenceKey] 1534 ?? bundle.object(forInfoDictionaryKey: fixtureEvidenceKey) as? String 1535 let networkProfile = 1536 values[networkProfileKey] 1537 ?? bundle.object(forInfoDictionaryKey: networkProfileKey) as? String 1538 let sourceCommit = value(sourceCommitKey, values: values, bundle: bundle) 1539 let sourceTree = value(sourceTreeKey, values: values, bundle: bundle) 1540 let appBuildSHA256 = value(appBuildSHA256Key, values: values, bundle: bundle) 1541 let simulatorID = value(simulatorIDKey, values: values, bundle: bundle) 1542 if runIDValue?.isEmpty != false, blossomValue?.isEmpty != false { 1543 throw XCTSkip("remote qualification inputs were not selected") 1544 } 1545 guard let runID = runIDValue, 1546 runID.range(of: "^[a-z0-9][a-z0-9-]{6,62}[a-z0-9]$", options: .regularExpression) != nil, 1547 let blossom = blossomValue, 1548 !blossom.isEmpty, 1549 let networkProfile, 1550 networkProfile == "public" || networkProfile == "simulator" 1551 else { 1552 throw QualificationError.missingEnvironment 1553 } 1554 _ = try QualificationEndpointPolicy( 1555 relayURLs: separated(relayValue), 1556 blossomOrigins: separated(blossom), 1557 profile: networkProfile 1558 ) 1559 if networkProfile == "simulator" { 1560 guard 1561 let fixtureControl, 1562 let fixtureEvidence, 1563 fixtureControl.utf8.count <= 1024, 1564 fixtureEvidence.utf8.count <= 1024, 1565 fixtureControl.hasPrefix("/"), 1566 fixtureEvidence.hasPrefix("/") 1567 else { 1568 throw QualificationError.missingEnvironment 1569 } 1570 } 1571 let evidenceBinding = PersonaEvidenceBinding( 1572 sourceCommit: sourceCommit, 1573 sourceTree: sourceTree, 1574 appBuildSHA256: appBuildSHA256, 1575 simulatorID: simulatorID 1576 ) 1577 return Self( 1578 runID: runID, 1579 relayURLs: separated(relayValue), 1580 blossomOrigins: separated(blossom), 1581 fixtureControl: fixtureControl.flatMap { $0.isEmpty ? nil : $0 }, 1582 fixtureEvidence: fixtureEvidence.flatMap { $0.isEmpty ? nil : $0 }, 1583 networkProfile: networkProfile, 1584 evidenceBinding: evidenceBinding 1585 ) 1586 } 1587 1588 private static func value( 1589 _ key: String, 1590 values: [String: String], 1591 bundle: Bundle 1592 ) -> String? { 1593 (values[key] ?? bundle.object(forInfoDictionaryKey: key) as? String) 1594 .flatMap { $0.isEmpty ? nil : $0 } 1595 } 1596 1597 private static func separated(_ value: String?) -> [String] { 1598 (value ?? "").split(separator: ",").map(String.init).filter { !$0.isEmpty } 1599 } 1600 } 1601 1602 private struct PersonaEvidenceBinding { 1603 let sourceCommit: String 1604 let sourceTree: String 1605 let appBuildSHA256: String 1606 let simulatorID: String 1607 1608 init?( 1609 sourceCommit: String?, 1610 sourceTree: String?, 1611 appBuildSHA256: String?, 1612 simulatorID: String? 1613 ) { 1614 guard let sourceCommit, 1615 sourceCommit.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil, 1616 let sourceTree, 1617 sourceTree.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil, 1618 let appBuildSHA256, 1619 appBuildSHA256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, 1620 let simulatorID, 1621 simulatorID.uppercased().range( 1622 of: "^[A-F0-9-]{36}$", 1623 options: .regularExpression 1624 ) != nil 1625 else { return nil } 1626 self.sourceCommit = sourceCommit 1627 self.sourceTree = sourceTree 1628 self.appBuildSHA256 = appBuildSHA256 1629 self.simulatorID = simulatorID.uppercased() 1630 } 1631 } 1632 1633 private struct PersonaAttemptTestInvocation: Encodable { 1634 let target: String 1635 let identifier: String 1636 let action: String 1637 let configuration: String 1638 } 1639 1640 private struct PersonaAttemptSource: Encodable { 1641 let commit: String 1642 let tree: String 1643 } 1644 1645 private struct PersonaAttemptSimulator: Encodable { 1646 let udid: String 1647 let os: String 1648 let architecture: String 1649 } 1650 1651 private struct PersonaAttemptUIObservation: Encodable { 1652 let validationAttempted: Bool 1653 let validationRejected: Bool 1654 let retryAttempts: Int 1655 let relaunches: Int 1656 let retentionVerified: Bool 1657 let todayProjectionVerified: Bool 1658 1659 enum CodingKeys: String, CodingKey { 1660 case validationAttempted = "validation_attempted" 1661 case validationRejected = "validation_rejected" 1662 case retryAttempts = "retry_attempts" 1663 case relaunches 1664 case retentionVerified = "retention_verified" 1665 case todayProjectionVerified = "today_projection_verified" 1666 } 1667 } 1668 1669 private struct PersonaInteractionObservation { 1670 let validationAttempted: Bool 1671 let validationRejected: Bool 1672 let retryAttempts: Int 1673 let relaunches: Int 1674 let progressiveDisclosure: Bool 1675 let accessibilitySemantics: Bool 1676 let keyboardFocus: Bool 1677 let visibleActions: Bool 1678 } 1679 1680 private struct PersonaAttemptObservation { 1681 let interaction: PersonaInteractionObservation 1682 let network: PersonaAttemptNetworkObservation 1683 let todayProjectionVerified: Bool 1684 let retentionVerified: Bool 1685 1686 var ui: PersonaAttemptUIObservation { 1687 PersonaAttemptUIObservation( 1688 validationAttempted: interaction.validationAttempted, 1689 validationRejected: interaction.validationRejected, 1690 retryAttempts: interaction.retryAttempts, 1691 relaunches: interaction.relaunches, 1692 retentionVerified: retentionVerified, 1693 todayProjectionVerified: todayProjectionVerified 1694 ) 1695 } 1696 1697 func confirmingRetention() -> Self { 1698 Self( 1699 interaction: interaction, 1700 network: network, 1701 todayProjectionVerified: todayProjectionVerified, 1702 retentionVerified: true 1703 ) 1704 } 1705 } 1706 1707 private struct PersonaAttemptNetworkObservation: Encodable { 1708 let state: String 1709 let acceptedConnections: Int 1710 let rejectedConnections: Int 1711 let nonLoopbackAttempts: Int 1712 let subscriptions: Int 1713 let acceptedEvents: Int 1714 let acceptedUploads: Int 1715 let retrievals: Int 1716 let unintendedPublications: Int 1717 let eventsAcceptedDuringExpectedFailure: Int 1718 let finalCandidateDataLoss: Int 1719 1720 enum CodingKeys: String, CodingKey { 1721 case state 1722 case acceptedConnections = "accepted_connections" 1723 case rejectedConnections = "rejected_connections" 1724 case nonLoopbackAttempts = "non_loopback_attempts" 1725 case subscriptions 1726 case acceptedEvents = "accepted_events" 1727 case acceptedUploads = "accepted_uploads" 1728 case retrievals 1729 case unintendedPublications = "unintended_publications" 1730 case eventsAcceptedDuringExpectedFailure = "events_accepted_during_expected_failure" 1731 case finalCandidateDataLoss = "final_candidate_data_loss" 1732 } 1733 } 1734 1735 private struct PersonaFixtureNetworkSnapshot { 1736 private static let keys: Set<String> = [ 1737 "schema", "schema_version", "personas", "flow_counts", "accepted_events", 1738 "event_kind_counts", "upload_attempts", "accepted_uploads", "retrievals", 1739 "distinct_identities", "unknown_attempts", "duplicate_attempts", 1740 "expected_failure_rejections", "events_accepted_during_expected_failures", 1741 "accepted_connections", "rejected_connections", "non_loopback_attempts", 1742 "production_network_contacts", "unintended_publications", "final_candidate_data_loss", 1743 ] 1744 1745 let acceptedConnections: Int 1746 let rejectedConnections: Int 1747 let nonLoopbackAttempts: Int 1748 let productionNetworkContacts: Int 1749 let subscriptions: Int 1750 let acceptedEvents: Int 1751 let acceptedUploads: Int 1752 let retrievals: Int 1753 let unintendedPublications: Int 1754 let eventsAcceptedDuringExpectedFailures: Int 1755 let finalCandidateDataLoss: Int 1756 1757 init(_ value: [String: Any]) throws { 1758 guard Set(value.keys) == Self.keys, 1759 value["schema"] as? String == "tera.ios.local-social.persona-evidence.v1", 1760 value["schema_version"] as? Int == 1, 1761 let acceptedConnections = value["accepted_connections"] as? Int, 1762 let rejectedConnections = value["rejected_connections"] as? Int, 1763 let nonLoopbackAttempts = value["non_loopback_attempts"] as? Int, 1764 let productionNetworkContacts = value["production_network_contacts"] as? Int, 1765 let acceptedEvents = value["accepted_events"] as? Int, 1766 let acceptedUploads = value["accepted_uploads"] as? Int, 1767 let retrievals = value["retrievals"] as? Int, 1768 let unintendedPublications = value["unintended_publications"] as? Int, 1769 let eventsAccepted = value["events_accepted_during_expected_failures"] as? Int, 1770 let finalCandidateDataLoss = value["final_candidate_data_loss"] as? Int, 1771 let personas = value["personas"] as? [[String: Any]], 1772 personas.count == 5, 1773 [ 1774 acceptedConnections, rejectedConnections, nonLoopbackAttempts, 1775 productionNetworkContacts, 1776 acceptedEvents, acceptedUploads, retrievals, unintendedPublications, 1777 eventsAccepted, finalCandidateDataLoss, 1778 ].allSatisfy({ 0 ... 61440 ~= $0 }) 1779 else { 1780 throw QualificationError.invalidFixtureEvidence 1781 } 1782 let subscriptions = try personas.reduce(into: 0) { total, persona in 1783 guard let value = persona["subscriptions"] as? Int, 0 ... 4096 ~= value else { 1784 throw QualificationError.invalidFixtureEvidence 1785 } 1786 total += value 1787 } 1788 self.acceptedConnections = acceptedConnections 1789 self.rejectedConnections = rejectedConnections 1790 self.nonLoopbackAttempts = nonLoopbackAttempts 1791 self.productionNetworkContacts = productionNetworkContacts 1792 self.subscriptions = subscriptions 1793 self.acceptedEvents = acceptedEvents 1794 self.acceptedUploads = acceptedUploads 1795 self.retrievals = retrievals 1796 self.unintendedPublications = unintendedPublications 1797 eventsAcceptedDuringExpectedFailures = eventsAccepted 1798 self.finalCandidateDataLoss = finalCandidateDataLoss 1799 } 1800 1801 func delta(from prior: Self) throws -> PersonaAttemptNetworkObservation { 1802 let current = [ 1803 acceptedConnections, rejectedConnections, nonLoopbackAttempts, subscriptions, 1804 productionNetworkContacts, 1805 acceptedEvents, acceptedUploads, retrievals, unintendedPublications, 1806 eventsAcceptedDuringExpectedFailures, finalCandidateDataLoss, 1807 ] 1808 let previous = [ 1809 prior.acceptedConnections, prior.rejectedConnections, prior.nonLoopbackAttempts, 1810 prior.subscriptions, prior.productionNetworkContacts, prior.acceptedEvents, 1811 prior.acceptedUploads, prior.retrievals, 1812 prior.unintendedPublications, prior.eventsAcceptedDuringExpectedFailures, 1813 prior.finalCandidateDataLoss, 1814 ] 1815 guard zip(current, previous).allSatisfy({ pair in pair.0 >= pair.1 }) else { 1816 throw QualificationError.invalidFixtureEvidence 1817 } 1818 let values = zip(current, previous).map { pair in pair.0 - pair.1 } 1819 guard values[2] == 0, values[4] == 0 else { 1820 throw QualificationError.invalidEndpointPolicy 1821 } 1822 return PersonaAttemptNetworkObservation( 1823 state: "measured", 1824 acceptedConnections: values[0], 1825 rejectedConnections: values[1], 1826 nonLoopbackAttempts: values[2], 1827 subscriptions: values[3], 1828 acceptedEvents: values[5], 1829 acceptedUploads: values[6], 1830 retrievals: values[7], 1831 unintendedPublications: values[8], 1832 eventsAcceptedDuringExpectedFailure: values[9], 1833 finalCandidateDataLoss: values[10] 1834 ) 1835 } 1836 } 1837 1838 private struct PersonaAttemptAccessibility: Encodable { 1839 let locale: String 1840 let contentSize: String 1841 let reduceMotion: Bool 1842 let progressiveDisclosure: Bool 1843 let labelsValuesTraits: Bool 1844 let keyboardFocus: Bool 1845 let visibleActions: Bool 1846 let voiceoverUserObserved: Bool 1847 1848 enum CodingKeys: String, CodingKey { 1849 case locale 1850 case contentSize = "content_size" 1851 case reduceMotion = "reduce_motion" 1852 case progressiveDisclosure = "progressive_disclosure" 1853 case labelsValuesTraits = "labels_values_traits" 1854 case keyboardFocus = "keyboard_focus" 1855 case visibleActions = "visible_actions" 1856 case voiceoverUserObserved = "voiceover_user_observed" 1857 } 1858 } 1859 1860 private struct PersonaAttemptArtifactDigest: Encodable { 1861 let role: String 1862 let sha256: String 1863 } 1864 1865 private struct PersonaAttemptEvidence: Encodable { 1866 let schema: String 1867 let schemaVersion: UInt16 1868 let testInvocation: PersonaAttemptTestInvocation 1869 let source: PersonaAttemptSource 1870 let appBuildSHA256: String 1871 let simulator: PersonaAttemptSimulator 1872 let runID: String 1873 let personaRunID: String 1874 let personaAlias: String 1875 let attemptID: String 1876 let attemptOrder: Int 1877 let flow: String 1878 let expectedFailure: String 1879 let publicIdentitySHA256: String 1880 let endpointPolicySHA256: String 1881 let uiObservation: PersonaAttemptUIObservation 1882 let networkObservation: PersonaAttemptNetworkObservation 1883 let accessibility: PersonaAttemptAccessibility 1884 let artifactDigests: [PersonaAttemptArtifactDigest] 1885 1886 enum CodingKeys: String, CodingKey { 1887 case schema 1888 case schemaVersion = "schema_version" 1889 case testInvocation = "test_invocation" 1890 case source 1891 case appBuildSHA256 = "app_build_sha256" 1892 case simulator 1893 case runID = "run_id" 1894 case personaRunID = "persona_run_id" 1895 case personaAlias = "persona_alias" 1896 case attemptID = "attempt_id" 1897 case attemptOrder = "attempt_order" 1898 case flow 1899 case expectedFailure = "expected_failure" 1900 case publicIdentitySHA256 = "public_identity_sha256" 1901 case endpointPolicySHA256 = "endpoint_policy_sha256" 1902 case uiObservation = "ui_observation" 1903 case networkObservation = "network_observation" 1904 case accessibility 1905 case artifactDigests = "artifact_digests" 1906 } 1907 } 1908 1909 private struct BootstrapReceipt: Codable { 1910 let schema: String 1911 let schemaVersion: UInt16 1912 let runID: String 1913 let publicKey: String 1914 let relayURLs: [String] 1915 let blossomOrigins: [String] 1916 let privateKeyPresent: Bool 1917 let interactiveAuthenticationRequired: Bool 1918 } 1919 1920 private struct PersonaSuite: Decodable { 1921 let schema: String 1922 let schemaVersion: UInt16 1923 let locale: String 1924 let mediaFixtureSHA256: String 1925 let personas: [Persona] 1926 1927 enum CodingKeys: String, CodingKey { 1928 case schema 1929 case schemaVersion = "schema_version" 1930 case locale 1931 case mediaFixtureSHA256 = "media_fixture_sha256" 1932 case personas 1933 } 1934 } 1935 1936 private struct Persona: Decodable { 1937 let alias: String 1938 let syntheticAgeBand: String 1939 let interactionProfile: String 1940 let attempts: [PersonaAttempt] 1941 1942 enum CodingKeys: String, CodingKey { 1943 case alias 1944 case syntheticAgeBand = "synthetic_age_band" 1945 case interactionProfile = "interaction_profile" 1946 case attempts 1947 } 1948 } 1949 1950 private struct PersonaAttempt: Decodable { 1951 let id: String 1952 let order: Int 1953 let flow: PersonaFlow 1954 let marker: String 1955 let expectedFailure: String 1956 1957 enum CodingKeys: String, CodingKey { 1958 case id 1959 case order 1960 case flow 1961 case marker 1962 case expectedFailure = "expected_failure" 1963 } 1964 } 1965 1966 private enum PersonaFlow: String, Decodable { 1967 case update = "Update" 1968 case photoUpdate = "PhotoUpdate" 1969 case ask = "Ask" 1970 case event = "Event" 1971 case foodAvailability = "FoodAvailability" 1972 1973 var uiLabel: String { 1974 switch self { 1975 case .update: "Update" 1976 case .photoUpdate: "Photo update" 1977 case .ask: "Ask" 1978 case .event: "Event" 1979 case .foodAvailability: "Food availability" 1980 } 1981 } 1982 } 1983 1984 private enum QualificationError: Error { 1985 case missingEnvironment 1986 case invalidEndpointPolicy 1987 case invalidFixtureEvidence 1988 case invalidPublicKey 1989 case missingProductSurface 1990 case productSubmissionFailed 1991 case invalidPersonaFixture 1992 }