field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 9df573e320843630930caa90e478b6cf502cc5e7
parent 0e8ebef8087086f93d33e5ac6638c8e86f2033b3
Author: triesap <tyson@radroots.org>
Date:   Fri, 28 Aug 2026 06:14:37 +0000

test(ios): qualify local social runtime

Diffstat:
MPackage.resolved | 4++--
MPackage.swift | 2+-
MREADME.md | 17+++++++++++++++++
MRadroots.xcodeproj/project.pbxproj | 2+-
MRadroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4++--
MRadroots/App/RadrootsRemoteQualification.swift | 276+++++++++++++++++++++++++++++++++++++++++--------------------------------------
MRadroots/State/RadrootsSessionStore.swift | 873+++++++++++++++++++++++++++++++++++++++----------------------------------------
MRadroots/Views/RadrootsAddView.swift | 28+++++++++++++++++++++++++---
MRadroots/Views/RadrootsTodayView.swift | 3+--
MRadrootsTests/RadrootsRemoteQualificationTests.swift | 156++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
MRadrootsUITests/Info.plist | 4++++
MRadrootsUITests/RadrootsRemoteQualificationUITests.swift | 1574++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mproject.yml | 2+-
Ascripts/local-social-fixture.py | 464+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/verify-package-contract.sh | 12++++++++++++
Mscripts/xcode.sh | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
16 files changed, 2203 insertions(+), 1311 deletions(-)

diff --git a/Package.resolved b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "d3f53e98235994a331e4e89ecab349c6a16904ee6548c208ff5ab644dc9d6a4c", + "originHash" : "2fd3a22c9447cdffbab12bb80ee311003c6a86bbf6a5facc00401faec6447012", "pins" : [ { "identity" : "apple_kit", "kind" : "remoteSourceControl", "location" : "https://github.com/radrootslabs/apple_kit.git", "state" : { - "revision" : "86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24" + "revision" : "e61386529422a6413a852f0233a196a88f4ded51" } }, { diff --git a/Package.swift b/Package.swift @@ -13,7 +13,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/radrootslabs/apple_kit.git", - revision: "86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24" + revision: "e61386529422a6413a852f0233a196a88f4ded51" ), ], targets: [ diff --git a/README.md b/README.md @@ -55,6 +55,23 @@ not installed. `make clean` removes only rebuildable external build output and the ignored XCFramework; it preserves tracked generated bindings, locks, snapshots, project sources, and user files. +The focused local-social scenario starts bounded loopback Nostr and Blossom +fixtures, then exercises the real app stores and installed Rust FFI on one +exact simulator. The test saves an unverified photo draft, relaunches, retries +from the durable draft against the enabled fixture, publishes all five product +types, and proves Today and the outbox survive another relaunch: + +```sh +RADROOTS_IOS_UI_TEST_RUN_ID=local-social-example \ +cargo extbuild run -- scripts/xcode.sh local-social-ui-test \ + 'platform=iOS Simulator,id=SIMULATOR-UDID' \ + local-social-example +``` + +This harness is Debug-only, binds only explicit loopback ports, records bounded +protocol evidence under the extbuild results root, and cannot become a +production endpoint fallback. + ## Package surface `Package.swift` publishes the `RadrootsApp` library used by the generated Xcode diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -651,7 +651,7 @@ repositoryURL = "https://github.com/radrootslabs/apple_kit.git"; requirement = { kind = revision; - revision = 86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24; + revision = e61386529422a6413a852f0233a196a88f4ded51; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "71a5f6d2f0d1ef8d7a25e44b7914b4327901c461e679864ca8dc17deff817975", + "originHash" : "184b785843a7de169c88e08e1158041b2087a653433ebd5bc29fd201325bf16e", "pins" : [ { "identity" : "apple_kit", "kind" : "remoteSourceControl", "location" : "https://github.com/radrootslabs/apple_kit.git", "state" : { - "revision" : "86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24" + "revision" : "e61386529422a6413a852f0233a196a88f4ded51" } }, { diff --git a/Radroots/App/RadrootsRemoteQualification.swift b/Radroots/App/RadrootsRemoteQualification.swift @@ -3,153 +3,165 @@ import RadrootsKit import RadrootsKitBindings struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { - static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE" - static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID" - static let relayURLsKey = "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS" - static let blossomOriginsKey = "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS" - static let mediaRelativePathKey = "RADROOTS_IOS_UI_TEST_MEDIA_RELATIVE_PATH" + static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE" + static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID" + static let relayURLsKey = "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS" + static let blossomOriginsKey = "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS" + static let mediaRelativePathKey = "RADROOTS_IOS_UI_TEST_MEDIA_RELATIVE_PATH" + static let networkProfileKey = "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE" - let runID: String - let relayURLs: [String] - let blossomOrigins: [String] - let mediaFile: RadrootsFileReference? + let runID: String + let relayURLs: [String] + let blossomOrigins: [String] + let mediaFile: RadrootsFileReference? + let runtimeMode: String - var keychainServicePrefix: String { - "org.radroots.ios.remote-qualification.\(runID)" - } + var keychainServicePrefix: String { + "org.radroots.ios.remote-qualification.\(runID)" + } - var identityMetadataKeyPrefix: String { - "\(keychainServicePrefix).identity" - } + var identityMetadataKeyPrefix: String { + "\(keychainServicePrefix).identity" + } - #if DEBUG - static func current( - environment: [String: String] = ProcessInfo.processInfo.environment - ) throws -> Self? { - guard environment[enabledKey] == "1" else { return nil } - let runID = try requiredRunID(environment[runIDKey]) - let relays = separatedValues(environment[relayURLsKey]) - let blossoms = separatedValues(environment[blossomOriginsKey]) - guard blossoms.count == 1 else { - throw RadrootsConfigurationError.invalid("qualification_blossom_origin") - } - let mediaFile = try environment[mediaRelativePathKey].map { raw in - guard raw == "qualification/input.png" else { - throw RadrootsConfigurationError.invalid("qualification_media_file") - } - return RadrootsFileReference(scope: .data, relativePath: raw) - } - return Self( - runID: runID, - relayURLs: relays, - blossomOrigins: blossoms, - mediaFile: mediaFile - ) + #if DEBUG + static func current( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> Self? { + guard environment[enabledKey] == "1" else { return nil } + let runID = try requiredRunID(environment[runIDKey]) + let relays = separatedValues(environment[relayURLsKey]) + let blossoms = separatedValues(environment[blossomOriginsKey]) + let runtimeMode: String + switch environment[networkProfileKey] { + case "public": runtimeMode = "production" + case "simulator": runtimeMode = "simulator" + default: + throw RadrootsConfigurationError.invalid("qualification_network_profile") + } + guard blossoms.count == 1 else { + throw RadrootsConfigurationError.invalid("qualification_blossom_origin") + } + let mediaFile = try environment[mediaRelativePathKey].map { raw in + guard raw == "qualification/input.png" else { + throw RadrootsConfigurationError.invalid("qualification_media_file") } + return RadrootsFileReference(scope: .data, relativePath: raw) + } + return Self( + runID: runID, + relayURLs: relays, + blossomOrigins: blossoms, + mediaFile: mediaFile, + runtimeMode: runtimeMode + ) + } - private static func requiredRunID(_ raw: String?) throws -> String { - guard let raw, - (8 ... 64).contains(raw.utf8.count), - raw == raw.lowercased(), - raw.unicodeScalars.allSatisfy({ - CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") - .contains($0) - }), - raw.first != "-", - raw.last != "-" - else { - throw RadrootsConfigurationError.invalid("qualification_run_id") - } - return raw - } + private static func requiredRunID(_ raw: String?) throws -> String { + guard let raw, + (8...64).contains(raw.utf8.count), + raw == raw.lowercased(), + raw.unicodeScalars.allSatisfy({ + CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") + .contains($0) + }), + raw.first != "-", + raw.last != "-" + else { + throw RadrootsConfigurationError.invalid("qualification_run_id") + } + return raw + } - private static func separatedValues(_ raw: String?) -> [String] { - guard let raw else { return [] } - return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t")) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - #else - static func current(environment _: [String: String] = [:]) throws -> Self? { - nil - } - #endif + private static func separatedValues(_ raw: String?) -> [String] { + guard let raw else { return [] } + return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + #else + static func current(environment _: [String: String] = [:]) throws -> Self? { + nil + } + #endif } #if DEBUG - private struct RadrootsRemoteQualificationBlossomAuthorization: Codable { - let schema: String - let schemaVersion: UInt16 - let id: String - let pubkey: String - let createdAt: UInt64 - let kind: UInt32 - let tags: [[String]] - let content: String - let signature: String + private struct RadrootsRemoteQualificationBlossomAuthorization: Codable { + let schema: String + let schemaVersion: UInt16 + let id: String + let pubkey: String + let createdAt: UInt64 + let kind: UInt32 + let tags: [[String]] + let content: String + let signature: String - enum CodingKeys: String, CodingKey { - case schema - case schemaVersion = "schema_version" - case id - case pubkey - case createdAt = "created_at" - case kind - case tags - case content - case signature = "sig" - } + enum CodingKeys: String, CodingKey { + case schema + case schemaVersion = "schema_version" + case id + case pubkey + case createdAt = "created_at" + case kind + case tags + case content + case signature = "sig" } + } - enum RadrootsRemoteQualificationEvidence { - static func recordBlossomAuthorization( - request: HostSigningRequest, - signatureHex: String - ) throws { - guard try RadrootsRemoteQualificationEnvironment.current() != nil else { return } - let evidence = RadrootsRemoteQualificationBlossomAuthorization( - schema: "radroots-ios-remote-qualification-bud11-v1", - schemaVersion: 1, - id: request.expectedEventId, - pubkey: request.publicKey, - createdAt: request.createdAtUnixS, - kind: request.kind, - tags: request.tags, - content: request.content, - signature: signatureHex - ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(evidence) - guard let documents = FileManager.default.urls( - for: .documentDirectory, - in: .userDomainMask - ).first else { - throw CocoaError(.fileNoSuchFile) - } - try data.write( - to: documents.appendingPathComponent( - "radroots-remote-qualification-bud11.json" - ), - options: .atomic - ) - } + enum RadrootsRemoteQualificationEvidence { + static func recordBlossomAuthorization( + request: HostSigningRequest, + signatureHex: String + ) throws { + guard try RadrootsRemoteQualificationEnvironment.current() != nil else { return } + let evidence = RadrootsRemoteQualificationBlossomAuthorization( + schema: "radroots-ios-remote-qualification-bud11-v1", + schemaVersion: 1, + id: request.expectedEventId, + pubkey: request.publicKey, + createdAt: request.createdAtUnixS, + kind: request.kind, + tags: request.tags, + content: request.content, + signature: signatureHex + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(evidence) + guard + let documents = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first + else { + throw CocoaError(.fileNoSuchFile) + } + try data.write( + to: documents.appendingPathComponent( + "radroots-remote-qualification-bud11.json" + ), + options: .atomic + ) } + } - final class RadrootsRemoteQualificationUserPresence: RadrootsUserPresence, Sendable { - func currentStatus() async throws -> RadrootsUserPresenceStatus { - RadrootsUserPresenceStatus( - support: .deviceCredential, - biometryKind: .none, - canEvaluateDeviceCredential: true, - canEvaluateBiometrics: false - ) - } + final class RadrootsRemoteQualificationUserPresence: RadrootsUserPresence, Sendable { + func currentStatus() async throws -> RadrootsUserPresenceStatus { + RadrootsUserPresenceStatus( + support: .deviceCredential, + biometryKind: .none, + canEvaluateDeviceCredential: true, + canEvaluateBiometrics: false + ) + } - func verify( - _ request: RadrootsUserPresenceRequest - ) async throws -> RadrootsUserPresenceResult { - RadrootsUserPresenceResult(policy: request.policy, verified: true) - } + func verify( + _ request: RadrootsUserPresenceRequest + ) async throws -> RadrootsUserPresenceResult { + RadrootsUserPresenceResult(policy: request.policy, verified: true) } + } #endif diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift @@ -3,492 +3,491 @@ import RadrootsKit import UIKit final class RadrootsProtectedDataMonitor: @unchecked Sendable { - private let lock = NSLock() - private var available: Bool + private let lock = NSLock() + private var available: Bool - init(available: Bool) { - self.available = available - } + init(available: Bool) { + self.available = available + } - func update(available: Bool) { - lock.withLock { self.available = available } - } + func update(available: Bool) { + lock.withLock { self.available = available } + } - func isAvailable() -> Bool { - lock.withLock { available } - } + func isAvailable() -> Bool { + lock.withLock { available } + } } enum RadrootsSessionPhase: Sendable, Equatable { - case starting - case identityRequired - case identityLocked(RadrootsAppIdentity) - case protectedDataUnavailable(RadrootsAppIdentity) - case recoveryRequired(RadrootsAppIdentity) - case corruptIdentity(RadrootsAppIdentity) - case configurationReconfigurationRequired(RadrootsConfigurationReconfigurationRequirement) - case running(RadrootsRuntimeSnapshot) - case stopped - case failed(RadrootsRuntimeFailure) + case starting + case identityRequired + case identityLocked(RadrootsAppIdentity) + case protectedDataUnavailable(RadrootsAppIdentity) + case recoveryRequired(RadrootsAppIdentity) + case corruptIdentity(RadrootsAppIdentity) + case configurationReconfigurationRequired(RadrootsConfigurationReconfigurationRequirement) + case running(RadrootsRuntimeSnapshot) + case stopped + case failed(RadrootsRuntimeFailure) } struct RadrootsConfigurationReconfigurationRequirement: Sendable, Equatable { - let generation: UInt64 - let previousBlossomConfigFingerprint: String? + let generation: UInt64 + let previousBlossomConfigFingerprint: String? } actor RadrootsSessionStore { - private let configurationStore: RadrootsConfigurationStore - private let identityStore: RadrootsIdentityStore - private let runtimeClient: RadrootsRuntimeClient - private let roots: RadrootsAppleFileRoots - private let protectedData: RadrootsProtectedDataMonitor - private let automatesQualificationIdentity: Bool - private var generation: UInt64 = 0 - private var phase: RadrootsSessionPhase = .starting + private let configurationStore: RadrootsConfigurationStore + private let identityStore: RadrootsIdentityStore + private let runtimeClient: RadrootsRuntimeClient + private let roots: RadrootsAppleFileRoots + private let protectedData: RadrootsProtectedDataMonitor + private let automatesQualificationIdentity: Bool + private var generation: UInt64 = 0 + private var phase: RadrootsSessionPhase = .starting - init( - configurationStore: RadrootsConfigurationStore, - identityStore: RadrootsIdentityStore, - runtimeClient: RadrootsRuntimeClient, - roots: RadrootsAppleFileRoots, - protectedData: RadrootsProtectedDataMonitor, - automatesQualificationIdentity: Bool = false - ) { - self.configurationStore = configurationStore - self.identityStore = identityStore - self.runtimeClient = runtimeClient - self.roots = roots - self.protectedData = protectedData - self.automatesQualificationIdentity = automatesQualificationIdentity - } + init( + configurationStore: RadrootsConfigurationStore, + identityStore: RadrootsIdentityStore, + runtimeClient: RadrootsRuntimeClient, + roots: RadrootsAppleFileRoots, + protectedData: RadrootsProtectedDataMonitor, + automatesQualificationIdentity: Bool = false + ) { + self.configurationStore = configurationStore + self.identityStore = identityStore + self.runtimeClient = runtimeClient + self.roots = roots + self.protectedData = protectedData + self.automatesQualificationIdentity = automatesQualificationIdentity + } - @MainActor - static func production( - bundle: Bundle = .main, - runtimeClient: RadrootsRuntimeClient = .production() - ) throws -> RadrootsSessionStore { - guard let bundleIdentifier = bundle.bundleIdentifier else { - throw RadrootsConfigurationError.missing("bundle_identifier") - } - let qualification = try RadrootsRemoteQualificationEnvironment.current() - let servicePrefix = - try qualification?.keychainServicePrefix - ?? requiredString( - "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", bundle: bundle - ) - let bootstrap = try RadrootsConfigurationBootstrap( - runtimeMode: qualification == nil - ? requiredString("RADROOTS_FIELD_IOS_RUNTIME_MODE", bundle: bundle) - : "production", - relayURLs: qualification?.relayURLs - ?? array("RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS", bundle: bundle), - blossomOrigins: qualification?.blossomOrigins - ?? array("RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS", bundle: bundle), - keychainServicePrefix: servicePrefix, - bundleIdentifier: bundleIdentifier, - appMetadata: RadrootsRuntimeAppMetadata( - bundleIdentifier: bundleIdentifier, - version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - ?? "0", - buildNumber: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0", - buildSHA: normalizedOptional( - bundle.object(forInfoDictionaryKey: "GIT_SHA") as? String - ) - ) - ) - let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier) - let protectedData = RadrootsProtectedDataMonitor( - available: UIApplication.shared.isProtectedDataAvailable - ) - return try RadrootsSessionStore( - configurationStore: RadrootsConfigurationStore(bootstrap: bootstrap, roots: roots), - identityStore: .production( - servicePrefix: servicePrefix, - protectedDataAvailable: { protectedData.isAvailable() }, - qualification: qualification - ), - runtimeClient: runtimeClient, - roots: roots, - protectedData: protectedData, - automatesQualificationIdentity: qualification != nil - ) + @MainActor + static func production( + bundle: Bundle = .main, + runtimeClient: RadrootsRuntimeClient = .production() + ) throws -> RadrootsSessionStore { + guard let bundleIdentifier = bundle.bundleIdentifier else { + throw RadrootsConfigurationError.missing("bundle_identifier") } + let qualification = try RadrootsRemoteQualificationEnvironment.current() + let servicePrefix = + try qualification?.keychainServicePrefix + ?? requiredString( + "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", bundle: bundle + ) + let bootstrap = RadrootsConfigurationBootstrap( + runtimeMode: try qualification?.runtimeMode + ?? requiredString("RADROOTS_FIELD_IOS_RUNTIME_MODE", bundle: bundle), + relayURLs: qualification?.relayURLs + ?? array("RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS", bundle: bundle), + blossomOrigins: qualification?.blossomOrigins + ?? array("RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS", bundle: bundle), + keychainServicePrefix: servicePrefix, + bundleIdentifier: bundleIdentifier, + appMetadata: RadrootsRuntimeAppMetadata( + bundleIdentifier: bundleIdentifier, + version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "0", + buildNumber: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0", + buildSHA: normalizedOptional( + bundle.object(forInfoDictionaryKey: "GIT_SHA") as? String + ) + ) + ) + let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier) + let protectedData = RadrootsProtectedDataMonitor( + available: UIApplication.shared.isProtectedDataAvailable + ) + return try RadrootsSessionStore( + configurationStore: RadrootsConfigurationStore(bootstrap: bootstrap, roots: roots), + identityStore: .production( + servicePrefix: servicePrefix, + protectedDataAvailable: { protectedData.isAvailable() }, + qualification: qualification + ), + runtimeClient: runtimeClient, + roots: roots, + protectedData: protectedData, + automatesQualificationIdentity: qualification != nil + ) + } - func updateProtectedDataAvailability(_ available: Bool) { - protectedData.update(available: available) - } + func updateProtectedDataAvailability(_ available: Bool) { + protectedData.update(available: available) + } - func currentPhase() -> RadrootsSessionPhase { - phase - } + func currentPhase() -> RadrootsSessionPhase { + phase + } - func start() async -> RadrootsSessionPhase { - await start(acceptingReconfiguration: false) - } + func start() async -> RadrootsSessionPhase { + await start(acceptingReconfiguration: false) + } - func applyConfigurationReconfiguration() async -> RadrootsSessionPhase { - await start(acceptingReconfiguration: true) - } + func applyConfigurationReconfiguration() async -> RadrootsSessionPhase { + await start(acceptingReconfiguration: true) + } - func applySettingsReconfiguration() async -> RadrootsSessionPhase { - generation &+= 1 - let requestedGeneration = generation - phase = .starting - do { - let identity = try await identityStore.loadAndMigrate() - guard identity.state == .unlocked else { - return await start() - } - let configuration = try await configurationStore.load() - phase = try await startRuntime( - configuration: configuration, - identity: identity, - generation: requestedGeneration, - forceReconfiguration: true, - adoptBootstrapSettings: false - ) - } catch { - guard generation == requestedGeneration else { return phase } - phase = .failed( - .local( - operation: "session.settings_reconfiguration", - code: "ios.session.settings_reconfiguration_failed", - safeMessage: "Radroots could not apply the saved settings." - ) - ) - } - return phase + func applySettingsReconfiguration() async -> RadrootsSessionPhase { + generation &+= 1 + let requestedGeneration = generation + phase = .starting + do { + let identity = try await identityStore.loadAndMigrate() + guard identity.state == .unlocked else { + return await start() + } + let configuration = try await configurationStore.load() + phase = try await startRuntime( + configuration: configuration, + identity: identity, + generation: requestedGeneration, + forceReconfiguration: true, + adoptBootstrapSettings: false + ) + } catch { + guard generation == requestedGeneration else { return phase } + phase = .failed( + .local( + operation: "session.settings_reconfiguration", + code: "ios.session.settings_reconfiguration_failed", + safeMessage: "Radroots could not apply the saved settings." + ) + ) } + return phase + } - func suspend() async { - generation &+= 1 - await runtimeClient.suspend() - if case .starting = phase { - phase = .stopped - } + func suspend() async { + generation &+= 1 + await runtimeClient.suspend() + if case .starting = phase { + phase = .stopped } + } - private func start(acceptingReconfiguration: Bool) async -> RadrootsSessionPhase { - generation &+= 1 - let requestedGeneration = generation - phase = .starting - do { - let identity = try await identityStore.loadAndMigrate() - guard generation == requestedGeneration else { - throw RadrootsRuntimeClientError.superseded - } - switch identity.state { - case .absent: - phase = .identityRequired - case .locked: - #if DEBUG - if automatesQualificationIdentity { - return await unlockIdentity() - } - #endif - phase = .identityLocked(identity) - case .protectedDataUnavailable: - phase = .protectedDataUnavailable(identity) - case .recoveryRequired: - phase = .recoveryRequired(identity) - case .corrupt: - phase = .corruptIdentity(identity) - case .unlocked: - let configuration = try await configurationStore.load() - if configuration.activationState == .reconfigurationRequired, - !acceptingReconfiguration - { - phase = .configurationReconfigurationRequired( - RadrootsConfigurationReconfigurationRequirement( - generation: configuration.generation, - previousBlossomConfigFingerprint: configuration - .previousBlossomConfigFingerprint - ) - ) - return phase - } - phase = try await startRuntime( - configuration: configuration, - identity: identity, - generation: requestedGeneration, - forceReconfiguration: configuration.activationState - == .reconfigurationRequired, - adoptBootstrapSettings: configuration.activationState - == .reconfigurationRequired - ) - } - } catch RadrootsRuntimeClientError.superseded { - return phase - } catch let RadrootsRuntimeClientError.startup(failure) { - guard generation == requestedGeneration else { return phase } - phase = .failed(failure) - } catch let error as LocalizedError { - guard generation == requestedGeneration else { return phase } - phase = .failed( - .local( - operation: "session.start", - code: "ios.session.start_failed", - safeMessage: error.errorDescription ?? "Radroots could not start." - ) - ) - } catch { - guard generation == requestedGeneration else { return phase } - phase = .failed( - .local( - operation: "session.start", - code: "ios.session.start_failed", - safeMessage: "Radroots could not start." - ) + private func start(acceptingReconfiguration: Bool) async -> RadrootsSessionPhase { + generation &+= 1 + let requestedGeneration = generation + phase = .starting + do { + let identity = try await identityStore.loadAndMigrate() + guard generation == requestedGeneration else { + throw RadrootsRuntimeClientError.superseded + } + switch identity.state { + case .absent: + phase = .identityRequired + case .locked: + #if DEBUG + if automatesQualificationIdentity { + return await unlockIdentity() + } + #endif + phase = .identityLocked(identity) + case .protectedDataUnavailable: + phase = .protectedDataUnavailable(identity) + case .recoveryRequired: + phase = .recoveryRequired(identity) + case .corrupt: + phase = .corruptIdentity(identity) + case .unlocked: + let configuration = try await configurationStore.load() + if configuration.activationState == .reconfigurationRequired, + !acceptingReconfiguration + { + phase = .configurationReconfigurationRequired( + RadrootsConfigurationReconfigurationRequirement( + generation: configuration.generation, + previousBlossomConfigFingerprint: configuration + .previousBlossomConfigFingerprint ) + ) + return phase } - return phase + phase = try await startRuntime( + configuration: configuration, + identity: identity, + generation: requestedGeneration, + forceReconfiguration: configuration.activationState + == .reconfigurationRequired, + adoptBootstrapSettings: configuration.activationState + == .reconfigurationRequired + ) + } + } catch RadrootsRuntimeClientError.superseded { + return phase + } catch let RadrootsRuntimeClientError.startup(failure) { + guard generation == requestedGeneration else { return phase } + phase = .failed(failure) + } catch let error as LocalizedError { + guard generation == requestedGeneration else { return phase } + phase = .failed( + .local( + operation: "session.start", + code: "ios.session.start_failed", + safeMessage: error.errorDescription ?? "Radroots could not start." + ) + ) + } catch { + guard generation == requestedGeneration else { return phase } + phase = .failed( + .local( + operation: "session.start", + code: "ios.session.start_failed", + safeMessage: "Radroots could not start." + ) + ) } + return phase + } - func createIdentity(label: String? = nil) async -> RadrootsSessionPhase { - do { - _ = try await identityStore.create(label: label) - } catch { - return failIdentityOperation(error) - } - return await start() + func createIdentity(label: String? = nil) async -> RadrootsSessionPhase { + do { + _ = try await identityStore.create(label: label) + } catch { + return failIdentityOperation(error) } + return await start() + } - func importIdentity( - _ material: RadrootsIdentitySecretMaterial, - label: String? = nil - ) async -> RadrootsSessionPhase { - do { - _ = try await identityStore.importIdentity(material, label: label) - } catch { - return failIdentityOperation(error) - } - return await start() + func importIdentity( + _ material: RadrootsIdentitySecretMaterial, + label: String? = nil + ) async -> RadrootsSessionPhase { + do { + _ = try await identityStore.importIdentity(material, label: label) + } catch { + return failIdentityOperation(error) } + return await start() + } - func lockIdentity() async -> RadrootsSessionPhase { - generation &+= 1 - if case .running = phase, - let settings = try? await runtimeClient.mobileSettings() - { - _ = try? await runtimeClient.applyIdentityCommand( - expectedRevision: settings.revision, - command: RadrootsIdentityCommand( - kind: .lock, - operationID: nil, - identityID: nil, - publicKeyHex: nil - ) - ) - } - _ = try? await runtimeClient.stop() - await identityStore.lock() - let identity = await identityStore.snapshot() - phase = .identityLocked(identity) - return phase + func lockIdentity() async -> RadrootsSessionPhase { + generation &+= 1 + if case .running = phase, + let settings = try? await runtimeClient.mobileSettings() + { + _ = try? await runtimeClient.applyIdentityCommand( + expectedRevision: settings.revision, + command: RadrootsIdentityCommand( + kind: .lock, + operationID: nil, + identityID: nil, + publicKeyHex: nil + ) + ) } + _ = try? await runtimeClient.stop() + await identityStore.lock() + let identity = await identityStore.snapshot() + phase = .identityLocked(identity) + return phase + } - func unlockIdentity() async -> RadrootsSessionPhase { - do { - _ = try await identityStore.unlock() - } catch { - return failIdentityOperation(error) - } - return await start() + func unlockIdentity() async -> RadrootsSessionPhase { + do { + _ = try await identityStore.unlock() + } catch { + return failIdentityOperation(error) } + return await start() + } - func recoverIdentity() async -> RadrootsSessionPhase { - do { - _ = try await identityStore.recover() - } catch { - return failIdentityOperation(error) - } - return await start() + func recoverIdentity() async -> RadrootsSessionPhase { + do { + _ = try await identityStore.recover() + } catch { + return failIdentityOperation(error) } + return await start() + } - func stop() async -> RadrootsSessionPhase { - generation &+= 1 - do { - _ = try await runtimeClient.stop() - await identityStore.lock() - phase = .stopped - } catch let RadrootsRuntimeClientError.shutdown(failure) { - phase = .failed(failure) - } catch { - phase = .failed( - .local( - operation: "session.stop", - code: "ios.session.stop_failed", - safeMessage: "Radroots could not finish shutting down." - ) - ) - } - return phase + func stop() async -> RadrootsSessionPhase { + generation &+= 1 + do { + _ = try await runtimeClient.stop() + await identityStore.lock() + phase = .stopped + } catch let RadrootsRuntimeClientError.shutdown(failure) { + phase = .failed(failure) + } catch { + phase = .failed( + .local( + operation: "session.stop", + code: "ios.session.stop_failed", + safeMessage: "Radroots could not finish shutting down." + ) + ) } + return phase + } - private func startRuntime( - configuration: RadrootsAppConfiguration, - identity: RadrootsAppIdentity, - generation requestedGeneration: UInt64, - forceReconfiguration: Bool, - adoptBootstrapSettings: Bool - ) async throws -> RadrootsSessionPhase { - guard let publicKeyHex = identity.publicKeyHex, - let signerGeneration = identity.signerGeneration - else { - throw RadrootsIdentityStoreError.unavailable - } - let mobileStore = try RadrootsAppleMobileStore.prepare( - roots: roots, - publicKeyHex: publicKeyHex, - protectedDataAvailability: protectedData.isAvailable() ? .available : .unavailable - ) - let sourceGeneration = try await configurationStore.sourceGeneration() - let signer = try await identityStore.signer(for: identity) - let launchConfiguration = RadrootsRuntimeLaunchConfiguration( - applicationSupportDirectory: mobileStore.applicationSupportDirectory.path, - publicKeyHex: publicKeyHex, - sourceGenerationHex: sourceGeneration.generationHex, - sourceGenerationCreatedAtUnixMilliseconds: sourceGeneration.createdAtUnixMilliseconds, - protectedData: protectedData.isAvailable() ? .available : .unavailable, - networkProfile: configuration.profile.runtimeValue, - writableRelays: configuration.writableRelays, - blossom: configuration.blossom, - app: configuration.appMetadata, - signerGeneration: signerGeneration, - signer: signer, - adoptBootstrapSettings: adoptBootstrapSettings - ) - let snapshot = - if forceReconfiguration { - try await runtimeClient.reconfigure(configuration: launchConfiguration) - } else { - try await runtimeClient.start(configuration: launchConfiguration) - } - guard generation == requestedGeneration else { - _ = try? await runtimeClient.stop() - throw RadrootsRuntimeClientError.superseded - } - try await reconcileIdentity(identity) - if configuration.activationState == .reconfigurationRequired, - adoptBootstrapSettings - { - try await configurationStore.confirmBootstrapActivation( - expectedGeneration: configuration.generation - ) - } - guard generation == requestedGeneration else { - _ = try? await runtimeClient.stop() - throw RadrootsRuntimeClientError.superseded - } - return .running(snapshot) + private func startRuntime( + configuration: RadrootsAppConfiguration, + identity: RadrootsAppIdentity, + generation requestedGeneration: UInt64, + forceReconfiguration: Bool, + adoptBootstrapSettings: Bool + ) async throws -> RadrootsSessionPhase { + guard let publicKeyHex = identity.publicKeyHex, + let signerGeneration = identity.signerGeneration + else { + throw RadrootsIdentityStoreError.unavailable } + let mobileStore = try RadrootsAppleMobileStore.prepare( + roots: roots, + publicKeyHex: publicKeyHex, + protectedDataAvailability: protectedData.isAvailable() ? .available : .unavailable + ) + let sourceGeneration = try await configurationStore.sourceGeneration() + let signer = try await identityStore.signer(for: identity) + let launchConfiguration = RadrootsRuntimeLaunchConfiguration( + applicationSupportDirectory: mobileStore.applicationSupportDirectory.path, + publicKeyHex: publicKeyHex, + sourceGenerationHex: sourceGeneration.generationHex, + sourceGenerationCreatedAtUnixMilliseconds: sourceGeneration.createdAtUnixMilliseconds, + protectedData: protectedData.isAvailable() ? .available : .unavailable, + networkProfile: configuration.profile.runtimeValue, + writableRelays: configuration.writableRelays, + blossom: configuration.blossom, + app: configuration.appMetadata, + signerGeneration: signerGeneration, + signer: signer, + adoptBootstrapSettings: adoptBootstrapSettings + ) + let snapshot = + if forceReconfiguration { + try await runtimeClient.reconfigure(configuration: launchConfiguration) + } else { + try await runtimeClient.start(configuration: launchConfiguration) + } + guard generation == requestedGeneration else { + _ = try? await runtimeClient.stop() + throw RadrootsRuntimeClientError.superseded + } + try await reconcileIdentity(identity) + if configuration.activationState == .reconfigurationRequired, + adoptBootstrapSettings + { + try await configurationStore.confirmBootstrapActivation( + expectedGeneration: configuration.generation + ) + } + guard generation == requestedGeneration else { + _ = try? await runtimeClient.stop() + throw RadrootsRuntimeClientError.superseded + } + return .running(snapshot) + } - private func reconcileIdentity(_ identity: RadrootsAppIdentity) async throws { - guard let identityID = identity.identityHandle, - let publicKeyHex = identity.publicKeyHex - else { - throw RadrootsIdentityStoreError.unavailable - } - var settings = try await runtimeClient.mobileSettings() - if let pending = settings.identity.pendingImportOperationID { - settings = try await runtimeClient.applyIdentityCommand( - expectedRevision: settings.revision, - command: RadrootsIdentityCommand( - kind: .cancelImport, - operationID: pending, - identityID: nil, - publicKeyHex: nil - ) - ).settings - } - if let existing = settings.identity.identities.first(where: { - $0.publicKeyHex == publicKeyHex - }) { - if settings.identity.activeIdentityID != existing.id { - settings = try await runtimeClient.applyIdentityCommand( - expectedRevision: settings.revision, - command: RadrootsIdentityCommand( - kind: .select, - operationID: nil, - identityID: existing.id, - publicKeyHex: nil - ) - ).settings - } - } else { - let operationID = UUID().uuidString.lowercased() - settings = try await runtimeClient.applyIdentityCommand( - expectedRevision: settings.revision, - command: RadrootsIdentityCommand( - kind: .beginImport, - operationID: operationID, - identityID: nil, - publicKeyHex: nil - ) - ).settings - settings = try await runtimeClient.applyIdentityCommand( - expectedRevision: settings.revision, - command: RadrootsIdentityCommand( - kind: .completeImport, - operationID: operationID, - identityID: identityID, - publicKeyHex: publicKeyHex - ) - ).settings - } - _ = try await runtimeClient.applyIdentityCommand( - expectedRevision: settings.revision, - command: RadrootsIdentityCommand( - kind: .unlock, - operationID: nil, - identityID: nil, - publicKeyHex: nil - ) + private func reconcileIdentity(_ identity: RadrootsAppIdentity) async throws { + guard let identityID = identity.identityHandle, + let publicKeyHex = identity.publicKeyHex + else { + throw RadrootsIdentityStoreError.unavailable + } + var settings = try await runtimeClient.mobileSettings() + if let pending = settings.identity.pendingImportOperationID { + settings = try await runtimeClient.applyIdentityCommand( + expectedRevision: settings.revision, + command: RadrootsIdentityCommand( + kind: .cancelImport, + operationID: pending, + identityID: nil, + publicKeyHex: nil ) + ).settings } - - private func failIdentityOperation(_ error: Error) -> RadrootsSessionPhase { - generation &+= 1 - let message = - (error as? LocalizedError)?.errorDescription - ?? "The local identity operation could not be completed." - phase = .failed( - .local( - operation: "identity.operation", - code: "ios.identity.operation_failed", - safeMessage: message - ) + if let existing = settings.identity.identities.first(where: { + $0.publicKeyHex == publicKeyHex + }) { + if settings.identity.activeIdentityID != existing.id { + settings = try await runtimeClient.applyIdentityCommand( + expectedRevision: settings.revision, + command: RadrootsIdentityCommand( + kind: .select, + operationID: nil, + identityID: existing.id, + publicKeyHex: nil + ) + ).settings + } + } else { + let operationID = UUID().uuidString.lowercased() + settings = try await runtimeClient.applyIdentityCommand( + expectedRevision: settings.revision, + command: RadrootsIdentityCommand( + kind: .beginImport, + operationID: operationID, + identityID: nil, + publicKeyHex: nil + ) + ).settings + settings = try await runtimeClient.applyIdentityCommand( + expectedRevision: settings.revision, + command: RadrootsIdentityCommand( + kind: .completeImport, + operationID: operationID, + identityID: identityID, + publicKeyHex: publicKeyHex ) - return phase + ).settings } + _ = try await runtimeClient.applyIdentityCommand( + expectedRevision: settings.revision, + command: RadrootsIdentityCommand( + kind: .unlock, + operationID: nil, + identityID: nil, + publicKeyHex: nil + ) + ) + } - @MainActor - private static func requiredString(_ key: String, bundle: Bundle) throws -> String { - guard let value = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else { - throw RadrootsConfigurationError.missing(key) - } - return value - } + private func failIdentityOperation(_ error: Error) -> RadrootsSessionPhase { + generation &+= 1 + let message = + (error as? LocalizedError)?.errorDescription + ?? "The local identity operation could not be completed." + phase = .failed( + .local( + operation: "identity.operation", + code: "ios.identity.operation_failed", + safeMessage: message + ) + ) + return phase + } - @MainActor - private static func array(_ key: String, bundle: Bundle) -> [String] { - if let values = bundle.object(forInfoDictionaryKey: key) as? [String] { - return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - guard let raw = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else { - return [] - } - return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t")) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + @MainActor + private static func requiredString(_ key: String, bundle: Bundle) throws -> String { + guard let value = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else { + throw RadrootsConfigurationError.missing(key) } + return value + } - @MainActor - private static func normalizedOptional(_ value: String?) -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty || trimmed == "unknown" ? nil : trimmed + @MainActor + private static func array(_ key: String, bundle: Bundle) -> [String] { + if let values = bundle.object(forInfoDictionaryKey: key) as? [String] { + return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } } + guard let raw = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else { + return [] + } + return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + @MainActor + private static func normalizedOptional(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty || trimmed == "unknown" ? nil : trimmed + } } diff --git a/Radroots/Views/RadrootsAddView.swift b/Radroots/Views/RadrootsAddView.swift @@ -1,9 +1,20 @@ import SwiftUI struct RadrootsAddView: View { + private enum FocusedField: Hashable { + case title + case summary + case content + case location + case price + case currency + case quantity + case media(String) + } + @ObservedObject var store: RadrootsAddStore @State private var showsDrafts = false - @FocusState private var isContentEditorFocused: Bool + @FocusState private var focusedField: FocusedField? var body: some View { Form { @@ -114,7 +125,7 @@ struct RadrootsAddView: View { } ToolbarItemGroup(placement: .keyboard) { Spacer() - Button("Done") { isContentEditorFocused = false } + Button("Done") { focusedField = nil } .accessibilityIdentifier("radroots.add.keyboard.done") } } @@ -143,9 +154,11 @@ struct RadrootsAddView: View { private var eventFields: some View { Section("Event") { TextField("Title", text: optional(\.title)) + .focused($focusedField, equals: .title) .accessibilityIdentifier("radroots.add.title") contentEditor(prompt: "Event details (optional)") TextField("Location (optional)", text: optional(\.location)) + .focused($focusedField, equals: .location) .accessibilityIdentifier("radroots.add.location") Picker("When", selection: optionalTiming) { ForEach(RadrootsEventTiming.allCases) { timing in @@ -167,18 +180,25 @@ struct RadrootsAddView: View { private var foodFields: some View { Section("Food availability") { TextField("Food", text: optional(\.title)) + .focused($focusedField, equals: .title) .accessibilityIdentifier("radroots.add.title") TextField("Short summary", text: optional(\.summary)) + .focused($focusedField, equals: .summary) + .accessibilityIdentifier("radroots.add.summary") contentEditor(prompt: "Details") TextField("Location", text: optional(\.location)) + .focused($focusedField, equals: .location) .accessibilityIdentifier("radroots.add.location") } Section("Price and quantity") { TextField("Price", text: optional(\.priceAmount)) .keyboardType(.decimalPad) + .focused($focusedField, equals: .price) .accessibilityIdentifier("radroots.add.price") TextField("Currency", text: optional(\.currency)) .textInputAutocapitalization(.characters) + .focused($focusedField, equals: .currency) + .accessibilityIdentifier("radroots.add.currency") Picker("Unit", selection: optional(\.unit)) { Text("Choose a unit").tag("") ForEach(unitChoices, id: \.self) { unit in @@ -188,6 +208,7 @@ struct RadrootsAddView: View { .accessibilityIdentifier("radroots.add.unit") TextField("Quantity available (optional)", text: optional(\.quantity)) .keyboardType(.decimalPad) + .focused($focusedField, equals: .quantity) } } @@ -209,6 +230,7 @@ struct RadrootsAddView: View { set: { store.updateMediaAlt(id: media.id, alt: $0) } ) ) + .focused($focusedField, equals: .media(media.id)) .accessibilityIdentifier("radroots.add.media.alt.\(media.id)") Button("Remove photo", role: .destructive) { store.removeMedia(id: media.id) } } @@ -245,7 +267,7 @@ struct RadrootsAddView: View { ZStack(alignment: .topLeading) { TextEditor(text: required(\.content)) .frame(minHeight: 110) - .focused($isContentEditorFocused) + .focused($focusedField, equals: .content) .accessibilityIdentifier("radroots.add.content") if store.form.content.isEmpty { Text(prompt) diff --git a/Radroots/Views/RadrootsTodayView.swift b/Radroots/Views/RadrootsTodayView.swift @@ -29,8 +29,8 @@ struct RadrootsTodayView: View { Text("Pull to refresh or add the first update to this local network.") } actions: { Button("Refresh") { Task { await store.reload() } } + .accessibilityIdentifier("radroots.today.refresh") } - .accessibilityIdentifier("radroots.today.empty") case .failed(let message) where store.cards.isEmpty: unavailableView( title: "Today is unavailable", @@ -78,7 +78,6 @@ struct RadrootsTodayView: View { ) } .task { await store.start() } - .accessibilityIdentifier("radroots.today.root") } private var feed: some View { diff --git a/RadrootsTests/RadrootsRemoteQualificationTests.swift b/RadrootsTests/RadrootsRemoteQualificationTests.swift @@ -1,77 +1,95 @@ -@testable import RadrootsApp import RadrootsKit import XCTest -final class RadrootsRemoteQualificationTests: XCTestCase { - func testQualificationIsOptInAndCarriesOnlyPublicInputs() throws { - XCTAssertNil(try RadrootsRemoteQualificationEnvironment.current(environment: [:])) +@testable import RadrootsApp - let value = try XCTUnwrap( - RadrootsRemoteQualificationEnvironment.current( - environment: [ - RadrootsRemoteQualificationEnvironment.enabledKey: "1", - RadrootsRemoteQualificationEnvironment.runIDKey: "gbrqv1-12345678", - RadrootsRemoteQualificationEnvironment.relayURLsKey: "wss://write.example", - RadrootsRemoteQualificationEnvironment.blossomOriginsKey: "https://media.example", - RadrootsRemoteQualificationEnvironment.mediaRelativePathKey: "qualification/input.png", - ] - ) - ) - XCTAssertEqual(value.runID, "gbrqv1-12345678") - XCTAssertEqual(value.relayURLs, ["wss://write.example"]) - XCTAssertEqual(value.blossomOrigins, ["https://media.example"]) - XCTAssertEqual( - value.mediaFile, - RadrootsFileReference(scope: .data, relativePath: "qualification/input.png") - ) - XCTAssertEqual( - value.keychainServicePrefix, - "org.radroots.ios.remote-qualification.gbrqv1-12345678" - ) - XCTAssertEqual( - value.identityMetadataKeyPrefix, - "org.radroots.ios.remote-qualification.gbrqv1-12345678.identity" - ) - } +final class RadrootsRemoteQualificationTests: XCTestCase { + func testQualificationIsOptInAndCarriesOnlyPublicInputs() throws { + XCTAssertNil(try RadrootsRemoteQualificationEnvironment.current(environment: [:])) - func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() { - let base = [ - RadrootsRemoteQualificationEnvironment.enabledKey: "1", - RadrootsRemoteQualificationEnvironment.runIDKey: "gbrqv1-12345678", - RadrootsRemoteQualificationEnvironment.blossomOriginsKey: "https://media.example", + let value = try XCTUnwrap( + RadrootsRemoteQualificationEnvironment.current( + environment: [ + RadrootsRemoteQualificationEnvironment.enabledKey: "1", + RadrootsRemoteQualificationEnvironment.runIDKey: "gbrqv1-12345678", + RadrootsRemoteQualificationEnvironment.relayURLsKey: "wss://write.example", + RadrootsRemoteQualificationEnvironment.blossomOriginsKey: "https://media.example", + RadrootsRemoteQualificationEnvironment.mediaRelativePathKey: "qualification/input.png", + RadrootsRemoteQualificationEnvironment.networkProfileKey: "public", ] - XCTAssertThrowsError( - try RadrootsRemoteQualificationEnvironment.current( - environment: base.merging([ - RadrootsRemoteQualificationEnvironment.runIDKey: "../../bad" - ]) { _, new in new } - ) - ) - XCTAssertThrowsError( - try RadrootsRemoteQualificationEnvironment.current( - environment: base.merging([ - RadrootsRemoteQualificationEnvironment.blossomOriginsKey: - "https://one.example,https://two.example" - ]) { _, new in new } - ) - ) - XCTAssertThrowsError( - try RadrootsRemoteQualificationEnvironment.current( - environment: base.merging([ - RadrootsRemoteQualificationEnvironment.mediaRelativePathKey: - "../../private-key" - ]) { _, new in new } - ) - ) - } + ) + ) + XCTAssertEqual(value.runID, "gbrqv1-12345678") + XCTAssertEqual(value.relayURLs, ["wss://write.example"]) + XCTAssertEqual(value.blossomOrigins, ["https://media.example"]) + XCTAssertEqual(value.runtimeMode, "production") + XCTAssertEqual( + value.mediaFile, + RadrootsFileReference(scope: .data, relativePath: "qualification/input.png") + ) + XCTAssertEqual( + value.keychainServicePrefix, + "org.radroots.ios.remote-qualification.gbrqv1-12345678" + ) + XCTAssertEqual( + value.identityMetadataKeyPrefix, + "org.radroots.ios.remote-qualification.gbrqv1-12345678.identity" + ) + } + + func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() { + let base = [ + RadrootsRemoteQualificationEnvironment.enabledKey: "1", + RadrootsRemoteQualificationEnvironment.runIDKey: "gbrqv1-12345678", + RadrootsRemoteQualificationEnvironment.blossomOriginsKey: "https://media.example", + RadrootsRemoteQualificationEnvironment.networkProfileKey: "public", + ] + XCTAssertThrowsError( + try RadrootsRemoteQualificationEnvironment.current( + environment: base.merging([ + RadrootsRemoteQualificationEnvironment.runIDKey: "../../bad" + ]) { _, new in new } + ) + ) + XCTAssertThrowsError( + try RadrootsRemoteQualificationEnvironment.current( + environment: base.merging([ + RadrootsRemoteQualificationEnvironment.networkProfileKey: "automatic" + ]) { _, new in new } + ) + ) + + let simulator = try? RadrootsRemoteQualificationEnvironment.current( + environment: base.merging([ + RadrootsRemoteQualificationEnvironment.networkProfileKey: "simulator" + ]) { _, new in new } + ) + XCTAssertEqual(simulator?.runtimeMode, "simulator") + XCTAssertThrowsError( + try RadrootsRemoteQualificationEnvironment.current( + environment: base.merging([ + RadrootsRemoteQualificationEnvironment.blossomOriginsKey: + "https://one.example,https://two.example" + ]) { _, new in new } + ) + ) + XCTAssertThrowsError( + try RadrootsRemoteQualificationEnvironment.current( + environment: base.merging([ + RadrootsRemoteQualificationEnvironment.mediaRelativePathKey: + "../../private-key" + ]) { _, new in new } + ) + ) + } - func testQualificationUserPresenceIsNoninteractiveAndDebugOnly() async throws { - let presence = RadrootsRemoteQualificationUserPresence() - let request = try RadrootsUserPresenceRequest(reason: "Automated remote qualification") - let result = try await presence.verify(request) - let status = try await presence.currentStatus() - XCTAssertTrue(result.verified) - XCTAssertEqual(result.policy, .deviceOwnerAuthentication) - XCTAssertFalse(status.canEvaluateBiometrics) - } + func testQualificationUserPresenceIsNoninteractiveAndDebugOnly() async throws { + let presence = RadrootsRemoteQualificationUserPresence() + let request = try RadrootsUserPresenceRequest(reason: "Automated remote qualification") + let result = try await presence.verify(request) + let status = try await presence.currentStatus() + XCTAssertTrue(result.verified) + XCTAssertEqual(result.policy, .deviceOwnerAuthentication) + XCTAssertFalse(status.canEvaluateBiometrics) + } } diff --git a/RadrootsUITests/Info.plist b/RadrootsUITests/Info.plist @@ -20,5 +20,9 @@ <string>$(RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS)</string> <key>RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS</key> <string>$(RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS)</string> + <key>RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL</key> + <string>$(RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL)</string> + <key>RADROOTS_IOS_UI_TEST_NETWORK_PROFILE</key> + <string>$(RADROOTS_IOS_UI_TEST_NETWORK_PROFILE)</string> </dict> </plist> diff --git a/RadrootsUITests/RadrootsRemoteQualificationUITests.swift b/RadrootsUITests/RadrootsRemoteQualificationUITests.swift @@ -1,694 +1,950 @@ import XCTest final class RadrootsRemoteQualificationUITests: XCTestCase { - private static let receiptName = "radroots-remote-qualification-bootstrap.json" - - @MainActor - func testBootstrapIdentityWithoutInteractiveAuthentication() throws { - let configuration = try QualificationConfiguration.environment() - let app = launchToRoot(configuration) - let publicKey = try readPublicKey(app) - try writeBootstrapReceipt(configuration: configuration, publicKey: publicKey) - - XCUIDevice.shared.press(.home) - app.activate() - XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10)) - - app.terminate() - app.launch() - reachRoot(app) - XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20)) - } - - @MainActor - func testStrictPublicEndpointRejectsPrivateAddress() throws { - let environment = try QualificationConfiguration.environment() - let configuration = QualificationConfiguration( - runID: environment.runID, - relayURLs: [], - blossomOrigins: ["https://127.0.0.1"] - ) - let app = XCUIApplication() - app.launchEnvironment = configuration.launchEnvironment - app.launch() - - let failureCode = app.descendants(matching: .any)["radroots.runtime.failure_code"] - for _ in 0 ..< 8 where !failureCode.exists { - for identifier in [ - "radroots.identity.create", - "radroots.identity.unlock", - "radroots.configuration.reconfigure", - "radroots.identity.recover", - ] { - let action = app.descendants(matching: .any)[identifier] - if action.exists, action.isHittable { - action.tap() - break - } - } - _ = failureCode.waitForExistence(timeout: 4) - } - XCTAssertEqual( - failureCode.label, - "Error code runtime_failed", - "A private Blossom endpoint did not expose the typed fail-closed result; " - + "failure_code.label=\(failureCode.exists ? failureCode.label : "missing")" - ) - XCTAssertFalse(app.tabBars.firstMatch.exists) - } - - @MainActor - func testRemoteBlossomUploadAndRecovery() throws { - let configuration = try QualificationConfiguration.environment() - let app = launchToRoot(configuration) - let marker = "Radroots remote qualification \(configuration.runID)" - - guard let submit = preparePhotoUpdate(app, marker: marker), - let terminalSubmitValue = submitAndWait(app, submit: submit) - else { return } - guard !terminalSubmitValue.contains("Error code") else { - return XCTFail("The Add submission failed locally; submit.value=\(terminalSubmitValue)") - } - - guard openDrafts(app) else { - return XCTFail("The Drafts sheet did not open after the upload attempt") - } - let mediaStatus = app.descendants(matching: .any).matching( - NSPredicate(format: "identifier BEGINSWITH 'radroots.add.draft.media_status.'") - ).firstMatch - guard mediaStatus.waitForExistence(timeout: 20) else { - app.buttons["Done"].tap() - let evidence = readBlossomFailureEvidence(app) - XCTFail("Draft media status was unavailable after upload; \(evidence)") - return - } - let mediaStatusLabel = mediaStatus.label - guard mediaStatusLabel.contains("1 of 1 photos verified") else { - app.buttons["Done"].tap() - let evidence = readBlossomFailureEvidence(app) - XCTFail( - "Remote Blossom upload was not verified; media=\(mediaStatusLabel); \(evidence)" - ) - return - } - app.buttons["Done"].tap() - - XCUIDevice.shared.press(.home) - app.activate() - XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10)) - - app.terminate() - app.launch() - reachRoot(app) - guard openAdd(app) != nil else { - return XCTFail("The Add bottom tab did not recover after relaunch") - } - app.buttons["radroots.add.drafts"].tap() - XCTAssertTrue( - app.descendants(matching: .any).matching( - NSPredicate(format: "label CONTAINS '1 of 1 photos verified'") - ).firstMatch.waitForExistence(timeout: 20) - ) - app.buttons["Done"].tap() - - if !configuration.relayURLs.isEmpty { - app.tabBars.buttons["Today"].tap() - let published = app.descendants(matching: .any).matching( - NSPredicate(format: "label CONTAINS %@", marker) - ).firstMatch - XCTAssertTrue(published.waitForExistence(timeout: 30)) - } + private static let receiptName = "radroots-remote-qualification-bootstrap.json" + + @MainActor + func testBootstrapIdentityWithoutInteractiveAuthentication() throws { + let configuration = try QualificationConfiguration.environment() + let app = launchToRoot(configuration) + let publicKey = try readPublicKey(app) + try writeBootstrapReceipt(configuration: configuration, publicKey: publicKey) + + XCUIDevice.shared.press(.home) + app.activate() + XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10)) + + app.terminate() + app.launch() + reachRoot(app) + XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20)) + } + + @MainActor + func testStrictPublicEndpointRejectsPrivateAddress() throws { + let environment = try QualificationConfiguration.environment() + let configuration = QualificationConfiguration( + runID: environment.runID, + relayURLs: [], + blossomOrigins: ["https://127.0.0.1"] + ) + let app = XCUIApplication() + app.launchEnvironment = configuration.launchEnvironment + app.launch() + + let failureCode = app.descendants(matching: .any)["radroots.runtime.failure_code"] + for _ in 0..<8 where !failureCode.exists { + for identifier in [ + "radroots.identity.create", + "radroots.identity.unlock", + "radroots.configuration.reconfigure", + "radroots.identity.recover", + ] { + let action = app.descendants(matching: .any)[identifier] + if action.exists, action.isHittable { + action.tap() + break + } + } + _ = failureCode.waitForExistence(timeout: 4) } - - @MainActor - func testRemoteBlossomUnavailablePersistsDraft() throws { - let configuration = try QualificationConfiguration.environment() - let app = launchToRoot(configuration) - let marker = "Radroots unavailable qualification \(configuration.runID)" - - guard let submit = preparePhotoUpdate(app, marker: marker), - let terminalSubmitValue = submitAndWait(app, submit: submit) - else { return } - XCTAssertTrue( - terminalSubmitValue.contains("Error code"), - "An unavailable Blossom endpoint did not produce a typed failure; " - + "submit.value=\(terminalSubmitValue)" - ) - XCTAssertTrue(assertUnverifiedDraft(app)) - - app.terminate() - app.launch() - reachRoot(app) - guard openAdd(app) != nil else { - return XCTFail("The Add bottom tab did not recover after unavailable relaunch") - } - XCTAssertTrue(assertUnverifiedDraft(app)) + XCTAssertEqual( + failureCode.label, + "Error code runtime_failed", + "A private Blossom endpoint did not expose the typed fail-closed result; " + + "failure_code.label=\(failureCode.exists ? failureCode.label : "missing")" + ) + XCTAssertFalse(app.tabBars.firstMatch.exists) + } + + @MainActor + func testLocalSocialFiveFlowScenario() throws { + let configuration = try QualificationConfiguration.environment() + let control = try XCTUnwrap(configuration.fixtureControl) + try? FileManager.default.removeItem(atPath: control) + var app = launchToRoot(configuration) + let marker = "\(configuration.runID)-photo" + + guard preparePhotoUpdate(app, marker: marker) != nil else { return } + let save = app.buttons["radroots.add.save"] + scrollTo(app, element: save) + XCTAssertTrue(save.waitForExistence(timeout: 10)) + XCTAssertTrue(waitUntilHittable(save, timeout: 10)) + save.tap() + let saved = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch + XCTAssertTrue( + waitForLabel(saved, label: "Draft saved on this device.", timeout: 20) + ) + XCTAssertTrue(assertUnverifiedDraft(app)) + app.terminate() + + XCTAssertTrue(FileManager.default.createFile(atPath: control, contents: Data())) + app = launchToRoot(configuration) + guard openAdd(app) != nil, openDrafts(app) else { + return XCTFail("The persisted media draft was unavailable after relaunch") } - - @MainActor - func testRemoteBlossomRetriesPersistedDraft() throws { - let configuration = try QualificationConfiguration.environment() - let app = launchToRoot(configuration) - guard openAdd(app) != nil else { - return XCTFail("The Add bottom tab did not present the Add surface") - } - guard openDrafts(app) else { - return XCTFail("The Drafts sheet did not open for retry") - } - let unverified = app.descendants(matching: .any).matching( - NSPredicate(format: "label CONTAINS '0 of 1 photos verified'") - ).firstMatch - XCTAssertTrue(unverified.waitForExistence(timeout: 20)) - let reopen = app.buttons["Reopen"].firstMatch - XCTAssertTrue(reopen.waitForExistence(timeout: 10)) - reopen.tap() - - guard let submit = readySubmit(app), - let terminalSubmitValue = submitAndWait(app, submit: submit) - else { return } - guard !terminalSubmitValue.contains("Error code") else { - return XCTFail("The persisted Blossom retry failed; submit.value=\(terminalSubmitValue)") - } - guard openDrafts(app) else { - return XCTFail("The Drafts sheet did not open after retry") - } - XCTAssertTrue( - app.descendants(matching: .any).matching( - NSPredicate(format: "label CONTAINS '1 of 1 photos verified'") - ).firstMatch.waitForExistence(timeout: 20) - ) - app.buttons["Done"].tap() - - app.terminate() - app.launch() - reachRoot(app) - XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20)) - } - - @MainActor - func testLocalCancellationPersistsAfterVerifiedUpload() throws { - let configuration = try QualificationConfiguration.environment() - let app = launchToRoot(configuration) - guard openAdd(app) != nil else { - return XCTFail("The Add bottom tab did not present the Add surface") - } - guard openDrafts(app) else { - return XCTFail("The Drafts sheet did not open for cancellation") - } - - let cancel = app.buttons["Cancel"].firstMatch - XCTAssertTrue(cancel.waitForExistence(timeout: 20)) - cancel.tap() - let cancelled = app.staticTexts.matching( - NSPredicate(format: "label == 'Cancelled'") - ).firstMatch - XCTAssertTrue(cancelled.waitForExistence(timeout: 20)) - app.buttons["Done"].tap() - - app.terminate() - app.launch() - reachRoot(app) - guard openAdd(app) != nil else { - return XCTFail("The Add bottom tab did not recover after cancellation") - } - guard openDrafts(app) else { - return XCTFail("The Drafts sheet did not reopen after cancellation") - } - let persistedCancelled = app.staticTexts.matching( - NSPredicate(format: "label == 'Cancelled'") - ).firstMatch - XCTAssertTrue(persistedCancelled.waitForExistence(timeout: 20)) - app.buttons["Done"].tap() - } - - @MainActor - func testManualDeviceLockAndProtectedDataRecovery() throws { - let configuration = try QualificationConfiguration.environment() - let app = launchToRoot(configuration) - XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20)) - - XCUIDevice.shared.press(.home) - Thread.sleep(forTimeInterval: 60) - - for _ in 0 ..< 24 { - app.activate() - if app.tabBars.firstMatch.waitForExistence(timeout: 5) { - return - } - } - XCTFail("Radroots did not recover after the physical device was unlocked") - } - - @MainActor - private func launchToRoot(_ configuration: QualificationConfiguration) -> XCUIApplication { - let app = XCUIApplication() - app.launchEnvironment = configuration.launchEnvironment - app.launch() - reachRoot(app) - return app - } - - @MainActor - private func reachRoot(_ app: XCUIApplication) { - for _ in 0 ..< 6 { - if app.tabBars.firstMatch.waitForExistence(timeout: 3) { - return - } - for identifier in [ - "radroots.identity.create", - "radroots.identity.unlock", - "radroots.configuration.reconfigure", - "radroots.identity.recover", - "radroots.runtime.retry", - ] { - let action = app.descendants(matching: .any)[identifier] - if action.exists, action.isHittable { - action.tap() - break - } - } - } - XCTFail("Radroots did not reach the two-tab root without interactive authentication") + let reopen = app.buttons["Reopen"].firstMatch + XCTAssertTrue(reopen.waitForExistence(timeout: 20)) + reopen.tap() + guard let retrySubmit = readySubmit(app), + let retryValue = submitAndWait(app, submit: retrySubmit) + else { return } + XCTAssertFalse( + retryValue.contains("Error code"), + "The preserved media draft did not publish after retry; submit.value=\(retryValue)" + ) + + let markers = [ + "\(configuration.runID)-update", + "\(configuration.runID)-ask", + "\(configuration.runID)-event", + "\(configuration.runID)-food", + ] + try publishTextFlow(app, type: "Update", marker: markers[0]) + try publishTextFlow(app, type: "Ask", marker: markers[1]) + try publishEvent(app, marker: markers[2]) + try publishFood(app, marker: markers[3]) + + let expected = [marker] + markers + assertTodayContains(app, markers: expected) + app.terminate() + app = launchToRoot(configuration) + assertTodayContains(app, markers: expected) + + guard openAdd(app) != nil, openDrafts(app) else { + return XCTFail("The durable outbox was unavailable after the final relaunch") + } + XCTAssertGreaterThanOrEqual( + app.buttons.matching(NSPredicate(format: "label == 'View'")).count, + 5 + ) + app.buttons["Done"].tap() + } + + @MainActor + func testRemoteBlossomUploadAndRecovery() throws { + let configuration = try QualificationConfiguration.environment() + let app = launchToRoot(configuration) + let marker = "Radroots remote qualification \(configuration.runID)" + + guard let submit = preparePhotoUpdate(app, marker: marker), + let terminalSubmitValue = submitAndWait(app, submit: submit) + else { return } + guard !terminalSubmitValue.contains("Error code") else { + return XCTFail("The Add submission failed locally; submit.value=\(terminalSubmitValue)") } - @MainActor - private func readPublicKey(_ app: XCUIApplication) throws -> String { - app.tabBars.buttons["Today"].tap() - let account = app.descendants(matching: .any)["radroots.support.account"] - XCTAssertTrue(account.waitForExistence(timeout: 10)) - account.tap() - - let settings = app.descendants(matching: .any)["radroots.support.settings"] - for _ in 0 ..< 5 where !settings.exists { - app.swipeUp() - } - XCTAssertTrue(settings.waitForExistence(timeout: 20)) - settings.tap() - - let publicKey = app.descendants(matching: .any)[ - "radroots.settings.identity.public_key" - ] - XCTAssertTrue(publicKey.waitForExistence(timeout: 10)) - guard let value = publicKey.value as? String, - value.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil - else { - throw QualificationError.invalidPublicKey - } - return value + guard openDrafts(app) else { + return XCTFail("The Drafts sheet did not open after the upload attempt") + } + let mediaStatus = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH 'radroots.add.draft.media_status.'") + ).firstMatch + guard mediaStatus.waitForExistence(timeout: 20) else { + app.buttons["Done"].tap() + let evidence = readBlossomFailureEvidence(app) + XCTFail("Draft media status was unavailable after upload; \(evidence)") + return + } + let mediaStatusLabel = mediaStatus.label + guard mediaStatusLabel.contains("1 of 1 photos verified") else { + app.buttons["Done"].tap() + let evidence = readBlossomFailureEvidence(app) + XCTFail( + "Remote Blossom upload was not verified; media=\(mediaStatusLabel); \(evidence)" + ) + return } + app.buttons["Done"].tap() - @MainActor - private func readBlossomFailureEvidence(_ app: XCUIApplication) -> String { - app.tabBars.buttons["Today"].tap() - let account = app.descendants(matching: .any)["radroots.support.account"] - guard account.waitForExistence(timeout: 10) else { return "settings=unavailable" } - account.tap() + XCUIDevice.shared.press(.home) + app.activate() + XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10)) - let settings = app.descendants(matching: .any)["radroots.support.settings"] - for _ in 0 ..< 5 where !settings.exists { - app.swipeUp() - } - guard settings.waitForExistence(timeout: 20) else { return "settings=unavailable" } - settings.tap() - - let httpStatus = app.descendants(matching: .any)[ - "radroots.settings.blossom.http_status" - ] - let errorCode = app.descendants(matching: .any)[ - "radroots.settings.blossom.error_code" - ] - let serverErrorCode = app.descendants(matching: .any)[ - "radroots.settings.blossom.server_error_code" - ] - for _ in 0 ..< 6 where !httpStatus.exists || !errorCode.exists || !serverErrorCode.exists { - app.swipeUp() - } - _ = httpStatus.waitForExistence(timeout: 10) - _ = errorCode.waitForExistence(timeout: 10) - _ = serverErrorCode.waitForExistence(timeout: 10) - return "http=\(httpStatus.exists ? httpStatus.label : "missing"), " - + "error=\(errorCode.exists ? errorCode.label : "missing"), " - + "server=\(serverErrorCode.exists ? serverErrorCode.label : "missing")" - } - - @MainActor - private func openAdd(_ app: XCUIApplication) -> XCUIElement? { - let add = app.tabBars.buttons["Add"] - guard add.waitForExistence(timeout: 10) else { return nil } - let type = app.descendants(matching: .any)["radroots.add.type"] - for _ in 0 ..< 3 { - add.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() - if type.waitForExistence(timeout: 10) { - return type - } - } - return nil + app.terminate() + app.launch() + reachRoot(app) + guard openAdd(app) != nil else { + return XCTFail("The Add bottom tab did not recover after relaunch") + } + app.buttons["radroots.add.drafts"].tap() + XCTAssertTrue( + app.descendants(matching: .any).matching( + NSPredicate(format: "label CONTAINS '1 of 1 photos verified'") + ).firstMatch.waitForExistence(timeout: 20) + ) + app.buttons["Done"].tap() + + if !configuration.relayURLs.isEmpty { + app.tabBars.buttons["Today"].tap() + let published = app.descendants(matching: .any).matching( + NSPredicate(format: "label CONTAINS %@", marker) + ).firstMatch + XCTAssertTrue(published.waitForExistence(timeout: 30)) + } + } + + @MainActor + func testRemoteBlossomUnavailablePersistsDraft() throws { + let configuration = try QualificationConfiguration.environment() + let app = launchToRoot(configuration) + let marker = "Radroots unavailable qualification \(configuration.runID)" + + guard let submit = preparePhotoUpdate(app, marker: marker), + let terminalSubmitValue = submitAndWait(app, submit: submit) + else { return } + XCTAssertTrue( + terminalSubmitValue.contains("Error code"), + "An unavailable Blossom endpoint did not produce a typed failure; " + + "submit.value=\(terminalSubmitValue)" + ) + XCTAssertTrue(assertUnverifiedDraft(app)) + + app.terminate() + app.launch() + reachRoot(app) + guard openAdd(app) != nil else { + return XCTFail("The Add bottom tab did not recover after unavailable relaunch") + } + XCTAssertTrue(assertUnverifiedDraft(app)) + } + + @MainActor + func testRemoteBlossomRetriesPersistedDraft() throws { + let configuration = try QualificationConfiguration.environment() + let app = launchToRoot(configuration) + guard openAdd(app) != nil else { + return XCTFail("The Add bottom tab did not present the Add surface") + } + guard openDrafts(app) else { + return XCTFail("The Drafts sheet did not open for retry") + } + let unverified = app.descendants(matching: .any).matching( + NSPredicate(format: "label CONTAINS '0 of 1 photos verified'") + ).firstMatch + XCTAssertTrue(unverified.waitForExistence(timeout: 20)) + let reopen = app.buttons["Reopen"].firstMatch + XCTAssertTrue(reopen.waitForExistence(timeout: 10)) + reopen.tap() + + guard let submit = readySubmit(app), + let terminalSubmitValue = submitAndWait(app, submit: submit) + else { return } + guard !terminalSubmitValue.contains("Error code") else { + return XCTFail("The persisted Blossom retry failed; submit.value=\(terminalSubmitValue)") + } + guard openDrafts(app) else { + return XCTFail("The Drafts sheet did not open after retry") + } + XCTAssertTrue( + app.descendants(matching: .any).matching( + NSPredicate(format: "label CONTAINS '1 of 1 photos verified'") + ).firstMatch.waitForExistence(timeout: 20) + ) + app.buttons["Done"].tap() + + app.terminate() + app.launch() + reachRoot(app) + XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20)) + } + + @MainActor + func testLocalCancellationPersistsAfterVerifiedUpload() throws { + let configuration = try QualificationConfiguration.environment() + let app = launchToRoot(configuration) + guard openAdd(app) != nil else { + return XCTFail("The Add bottom tab did not present the Add surface") + } + guard openDrafts(app) else { + return XCTFail("The Drafts sheet did not open for cancellation") } - @MainActor - private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? { - guard let type = openAdd(app) else { - XCTFail("The Add bottom tab did not present the Add surface") - return nil - } - let newDraft = app.buttons["radroots.add.new"] - XCTAssertTrue(newDraft.waitForExistence(timeout: 10)) - newDraft.tap() - - for _ in 0 ..< 3 where !app.buttons["Photo update"].exists { - type.tap() - _ = app.buttons["Photo update"].waitForExistence(timeout: 10) - } - let photoUpdate = app.buttons["Photo update"] - guard photoUpdate.exists else { - XCTFail("The Add type picker did not present Photo update") - return nil - } - guard waitUntilHittable(photoUpdate, timeout: 10) else { - XCTFail("Photo update did not become hittable") - return nil - } + let cancel = app.buttons["Cancel"].firstMatch + XCTAssertTrue(cancel.waitForExistence(timeout: 20)) + cancel.tap() + let cancelled = app.staticTexts.matching( + NSPredicate(format: "label == 'Cancelled'") + ).firstMatch + XCTAssertTrue(cancelled.waitForExistence(timeout: 20)) + app.buttons["Done"].tap() + + app.terminate() + app.launch() + reachRoot(app) + guard openAdd(app) != nil else { + return XCTFail("The Add bottom tab did not recover after cancellation") + } + guard openDrafts(app) else { + return XCTFail("The Drafts sheet did not reopen after cancellation") + } + let persistedCancelled = app.staticTexts.matching( + NSPredicate(format: "label == 'Cancelled'") + ).firstMatch + XCTAssertTrue(persistedCancelled.waitForExistence(timeout: 20)) + app.buttons["Done"].tap() + } + + @MainActor + func testManualDeviceLockAndProtectedDataRecovery() throws { + let configuration = try QualificationConfiguration.environment() + let app = launchToRoot(configuration) + XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 20)) + + XCUIDevice.shared.press(.home) + Thread.sleep(forTimeInterval: 60) + + for _ in 0..<24 { + app.activate() + if app.tabBars.firstMatch.waitForExistence(timeout: 5) { + return + } + } + XCTFail("Radroots did not recover after the physical device was unlocked") + } + + @MainActor + private func launchToRoot(_ configuration: QualificationConfiguration) -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment = configuration.launchEnvironment + app.launch() + reachRoot(app) + return app + } + + @MainActor + private func reachRoot(_ app: XCUIApplication) { + for _ in 0..<6 { + if app.tabBars.firstMatch.waitForExistence(timeout: 3) { + return + } + for identifier in [ + "radroots.identity.create", + "radroots.identity.unlock", + "radroots.configuration.reconfigure", + "radroots.identity.recover", + "radroots.runtime.retry", + ] { + let action = app.descendants(matching: .any)[identifier] + if action.exists, action.isHittable { + action.tap() + break + } + } + } + XCTFail("Radroots did not reach the two-tab root without interactive authentication") + } + + @MainActor + private func readPublicKey(_ app: XCUIApplication) throws -> String { + app.tabBars.buttons["Today"].tap() + let account = app.descendants(matching: .any)["radroots.support.account"] + XCTAssertTrue(account.waitForExistence(timeout: 10)) + account.tap() + + let settings = app.descendants(matching: .any)["radroots.support.settings"] + for _ in 0..<5 where !settings.exists { + app.swipeUp() + } + XCTAssertTrue(settings.waitForExistence(timeout: 20)) + settings.tap() + + let publicKey = app.descendants(matching: .any)[ + "radroots.settings.identity.public_key" + ] + XCTAssertTrue(publicKey.waitForExistence(timeout: 10)) + guard let value = publicKey.value as? String, + value.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil + else { + throw QualificationError.invalidPublicKey + } + return value + } + + @MainActor + private func readBlossomFailureEvidence(_ app: XCUIApplication) -> String { + app.tabBars.buttons["Today"].tap() + let account = app.descendants(matching: .any)["radroots.support.account"] + guard account.waitForExistence(timeout: 10) else { return "settings=unavailable" } + account.tap() + + let settings = app.descendants(matching: .any)["radroots.support.settings"] + for _ in 0..<5 where !settings.exists { + app.swipeUp() + } + guard settings.waitForExistence(timeout: 20) else { return "settings=unavailable" } + settings.tap() + + let httpStatus = app.descendants(matching: .any)[ + "radroots.settings.blossom.http_status" + ] + let errorCode = app.descendants(matching: .any)[ + "radroots.settings.blossom.error_code" + ] + let serverErrorCode = app.descendants(matching: .any)[ + "radroots.settings.blossom.server_error_code" + ] + for _ in 0..<6 where !httpStatus.exists || !errorCode.exists || !serverErrorCode.exists { + app.swipeUp() + } + _ = httpStatus.waitForExistence(timeout: 10) + _ = errorCode.waitForExistence(timeout: 10) + _ = serverErrorCode.waitForExistence(timeout: 10) + return "http=\(httpStatus.exists ? httpStatus.label : "missing"), " + + "error=\(errorCode.exists ? errorCode.label : "missing"), " + + "server=\(serverErrorCode.exists ? serverErrorCode.label : "missing")" + } + + @MainActor + private func openAdd(_ app: XCUIApplication) -> XCUIElement? { + let add = app.tabBars.buttons["Add"] + guard add.waitForExistence(timeout: 10) else { return nil } + let type = app.descendants(matching: .any)["radroots.add.type"] + for _ in 0..<3 { + add.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + if type.waitForExistence(timeout: 10) { + return type + } + } + return nil + } + + @MainActor + private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? { + guard let type = openAdd(app) else { + XCTFail("The Add bottom tab did not present the Add surface") + return nil + } + let newDraft = app.buttons["radroots.add.new"] + XCTAssertTrue(newDraft.waitForExistence(timeout: 10)) + newDraft.tap() - let content = app.descendants(matching: .any)["radroots.add.content"] - var selectedPhotoUpdate = false - for _ in 0 ..< 3 where !selectedPhotoUpdate { - photoUpdate.tap() - selectedPhotoUpdate = waitForValue(type, value: "Photo update", timeout: 10) - if !selectedPhotoUpdate, !photoUpdate.exists { - type.tap() - _ = photoUpdate.waitForExistence(timeout: 10) - } - } - guard selectedPhotoUpdate else { - XCTFail("Photo update selection did not update the Add composer type") - return nil - } - XCTAssertTrue(content.waitForExistence(timeout: 10)) - guard waitUntilHittable(content, timeout: 10) else { - XCTFail("Photo update content did not become hittable") - return nil - } - content.tap() - content.typeText(marker) - let keyboardDone = app.buttons["radroots.add.keyboard.done"] - XCTAssertTrue(keyboardDone.waitForExistence(timeout: 10)) - keyboardDone.tap() - XCTAssertFalse(app.keyboards.firstMatch.waitForExistence(timeout: 5)) - - let library = app.descendants(matching: .any)["radroots.add.media.library"] - XCTAssertTrue(library.waitForExistence(timeout: 10)) - XCTAssertTrue(library.isEnabled) - library.tap() - let prepared = app.descendants(matching: .any).matching( - NSPredicate( - format: "identifier MATCHES %@", - #"radroots\.add\.media\.[0-9a-f]{64}"# - ) - ).firstMatch - XCTAssertTrue(prepared.waitForExistence(timeout: 30)) - return readySubmit(app) - } - - @MainActor - private func readySubmit(_ app: XCUIApplication) -> XCUIElement? { - let submit = app.descendants(matching: .any)["radroots.add.submit"] - let addRoot = app.descendants(matching: .any)["radroots.add.root"] - for _ in 0 ..< 8 { - if isUnobscured(submit, above: app.tabBars.firstMatch) { - break - } - addRoot.swipeUp() - } - guard submit.waitForExistence(timeout: 10), - submit.isEnabled, - waitUntilHittable(submit, timeout: 10), - isUnobscured(submit, above: app.tabBars.firstMatch) - else { - XCTFail( - "The Add submission control did not become unobscured; " - + submissionDiagnostics(app, submit: submit) - ) - return nil - } - return submit - } - - @MainActor - private func submitAndWait(_ app: XCUIApplication, submit: XCUIElement) -> String? { - let priorSubmitValue = submit.value as? String - guard tapSubmitUntilWorkStarts( - app, - submit: submit, - priorSubmitValue: priorSubmitValue - ) else { - XCTFail( - "The Add submission tap did not start local work; " - + submissionDiagnostics(app, submit: submit) - ) - return nil - } - guard waitForWorkToFinish( - app, - submit: submit, - priorSubmitValue: priorSubmitValue - ) else { - XCTFail( - "The Add submission did not reach a terminal local state; " - + submissionDiagnostics(app, submit: submit) - ) - return nil - } - return submit.value as? String ?? "missing" + for _ in 0..<3 where !app.buttons["Photo update"].exists { + type.tap() + _ = app.buttons["Photo update"].waitForExistence(timeout: 10) + } + let photoUpdate = app.buttons["Photo update"] + guard photoUpdate.exists else { + XCTFail("The Add type picker did not present Photo update") + return nil + } + guard waitUntilHittable(photoUpdate, timeout: 10) else { + XCTFail("Photo update did not become hittable") + return nil } - @MainActor - private func assertUnverifiedDraft(_ app: XCUIApplication) -> Bool { - guard openDrafts(app) else { - XCTFail("The Drafts sheet did not open after the unavailable attempt") - return false - } - let mediaStatus = app.descendants(matching: .any).matching( - NSPredicate(format: "label CONTAINS '0 of 1 photos verified'") - ).firstMatch - let exists = mediaStatus.waitForExistence(timeout: 20) - XCTAssertTrue(exists) - if exists { - XCTAssertTrue( - mediaStatus.label == "0 of 1 photos verified" - || mediaStatus.label == "0 of 1 photos verified; 1 possible orphan", - "An unavailable upload did not preserve a bounded retry state; " - + "media_status.label=\(mediaStatus.label)" - ) - } - app.buttons["Done"].tap() - return exists - } - - @MainActor - private func waitForWorkToFinish( - _ app: XCUIApplication, - submit: XCUIElement, - priorSubmitValue: String? - ) -> Bool { - let progress = app.descendants(matching: .any)["radroots.add.progress"] - let finished = NSPredicate { _, _ in - let submitValue = submit.value as? String - return submit.exists - && !progress.exists - && submitValue != priorSubmitValue - && submitValue != "Working" - } - let expectation = XCTNSPredicateExpectation(predicate: finished, object: app) - return XCTWaiter.wait(for: [expectation], timeout: 180) == .completed - } - - @MainActor - private func tapSubmitUntilWorkStarts( - _ app: XCUIApplication, - submit: XCUIElement, - priorSubmitValue: String? - ) -> Bool { - let progress = app.descendants(matching: .any)["radroots.add.progress"] - let started = NSPredicate { _, _ in - let submitValue = submit.value as? String - return progress.exists - || submitValue != priorSubmitValue - } + let content = app.descendants(matching: .any)["radroots.add.content"] + var selectedPhotoUpdate = false + for _ in 0..<3 where !selectedPhotoUpdate { + photoUpdate.tap() + selectedPhotoUpdate = waitForValue(type, value: "Photo update", timeout: 10) + if !selectedPhotoUpdate, !photoUpdate.exists { + type.tap() + _ = photoUpdate.waitForExistence(timeout: 10) + } + } + guard selectedPhotoUpdate else { + XCTFail("Photo update selection did not update the Add composer type") + return nil + } + XCTAssertTrue(content.waitForExistence(timeout: 10)) + guard waitUntilHittable(content, timeout: 10) else { + XCTFail("Photo update content did not become hittable") + return nil + } + content.tap() + content.typeText(marker) + let keyboardDone = app.buttons["radroots.add.keyboard.done"] + XCTAssertTrue(keyboardDone.waitForExistence(timeout: 10)) + keyboardDone.tap() + XCTAssertFalse(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + + let library = app.descendants(matching: .any)["radroots.add.media.library"] + XCTAssertTrue(library.waitForExistence(timeout: 10)) + XCTAssertTrue(library.isEnabled) + library.tap() + let prepared = app.descendants(matching: .any).matching( + NSPredicate( + format: "identifier MATCHES %@", + #"radroots\.add\.media\.[0-9a-f]{64}"# + ) + ).firstMatch + XCTAssertTrue(prepared.waitForExistence(timeout: 30)) + return readySubmit(app) + } + + @MainActor + private func publishTextFlow( + _ app: XCUIApplication, + type: String, + marker: String + ) throws { + try beginDraft(app, type: type) + try enterText(app, identifier: "radroots.add.content", value: marker) + try submitSuccessfully(app) + } + + @MainActor + private func publishEvent(_ app: XCUIApplication, marker: String) throws { + try beginDraft(app, type: "Event") + try enterText(app, identifier: "radroots.add.title", value: marker) + try submitSuccessfully(app) + } + + @MainActor + private func publishFood(_ app: XCUIApplication, marker: String) throws { + try beginDraft(app, type: "Food availability") + try enterText(app, identifier: "radroots.add.title", value: marker) + try enterText(app, identifier: "radroots.add.summary", value: "Fresh local food") + try enterText(app, identifier: "radroots.add.content", value: "Available today") + try enterText(app, identifier: "radroots.add.location", value: "Town square") + try enterText(app, identifier: "radroots.add.price", value: "3") + try enterText(app, identifier: "radroots.add.currency", value: "CAD") + let unit = app.descendants(matching: .any)["radroots.add.unit"] + scrollTo(app, element: unit) + XCTAssertTrue(unit.waitForExistence(timeout: 10)) + unit.tap() + let pounds = app.buttons["lb"] + XCTAssertTrue(pounds.waitForExistence(timeout: 10)) + pounds.tap() + try submitSuccessfully(app) + } + + @MainActor + private func beginDraft(_ app: XCUIApplication, type: String) throws { + guard let picker = openAdd(app) else { + XCTFail("The Add bottom tab did not present the real Add store") + throw QualificationError.missingProductSurface + } + let newDraft = app.buttons["radroots.add.new"] + XCTAssertTrue(newDraft.waitForExistence(timeout: 10)) + newDraft.tap() + if picker.value as? String == type { + return + } + picker.tap() + let option = app.buttons[type] + XCTAssertTrue(option.waitForExistence(timeout: 10)) + option.tap() + guard waitForValue(picker, value: type, timeout: 10) else { + XCTFail("The Add type picker did not select \(type)") + throw QualificationError.missingProductSurface + } + } + + @MainActor + private func enterText( + _ app: XCUIApplication, + identifier: String, + value: String + ) throws { + let field = app.descendants(matching: .any)[identifier] + scrollTo(app, element: field) + guard field.waitForExistence(timeout: 10), waitUntilHittable(field, timeout: 10) else { + XCTFail("The Add field \(identifier) was unavailable") + throw QualificationError.missingProductSurface + } + guard focusForTextInput(field, timeout: 10) else { + XCTFail("The Add field \(identifier) did not accept keyboard focus") + throw QualificationError.missingProductSurface + } + field.typeText(value) + let done = app.buttons["radroots.add.keyboard.done"] + if done.waitForExistence(timeout: 5) { + done.tap() + XCTAssertFalse(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + } + } + + @MainActor + private func submitSuccessfully(_ app: XCUIApplication) throws { + guard let submit = readySubmit(app), + let value = submitAndWait(app, submit: submit) + else { + throw QualificationError.missingProductSurface + } + guard !value.contains("Error code") else { + XCTFail("The local-social flow failed: \(value)") + throw QualificationError.productSubmissionFailed + } + } - for _ in 0 ..< 3 { - submit.tap() - let expectation = XCTNSPredicateExpectation(predicate: started, object: app) - if XCTWaiter.wait(for: [expectation], timeout: 5) == .completed { - return true - } - guard waitUntilHittable(submit, timeout: 5), - isUnobscured(submit, above: app.tabBars.firstMatch) - else { - return false - } - } - return false + @MainActor + private func scrollTo(_ app: XCUIApplication, element: XCUIElement) { + let root = app.descendants(matching: .any)["radroots.add.root"] + for _ in 0..<8 where !element.exists || !element.isHittable { + root.swipeUp() + } + } + + @MainActor + private func assertTodayContains(_ app: XCUIApplication, markers: [String]) { + app.tabBars.buttons["Today"].tap() + let refresh = app.buttons["radroots.today.refresh"] + if refresh.waitForExistence(timeout: 5) { + refresh.tap() + } + let feed = app.descendants(matching: .any)["radroots.today.feed"].firstMatch + guard feed.waitForExistence(timeout: 30) else { + XCTFail("The local relay refresh did not materialize the Today feed") + return + } + for marker in markers.reversed() { + let card = app.descendants(matching: .any).matching( + NSPredicate(format: "label CONTAINS %@", marker) + ).firstMatch + for _ in 0..<6 where !card.exists { + feed.swipeUp() + } + XCTAssertTrue(card.waitForExistence(timeout: 30), "Missing Today card \(marker)") + } + } + + @MainActor + private func readySubmit(_ app: XCUIApplication) -> XCUIElement? { + let submit = app.descendants(matching: .any)["radroots.add.submit"] + let addRoot = app.descendants(matching: .any)["radroots.add.root"] + for _ in 0..<8 { + if isUnobscured(submit, above: app.tabBars.firstMatch) { + break + } + addRoot.swipeUp() + } + guard submit.waitForExistence(timeout: 10), + submit.isEnabled, + waitUntilHittable(submit, timeout: 10), + isUnobscured(submit, above: app.tabBars.firstMatch) + else { + XCTFail( + "The Add submission control did not become unobscured; " + + submissionDiagnostics(app, submit: submit) + ) + return nil + } + return submit + } + + @MainActor + private func submitAndWait(_ app: XCUIApplication, submit: XCUIElement) -> String? { + let priorSubmitValue = submit.value as? String + guard + tapSubmitUntilWorkStarts( + app, + submit: submit, + priorSubmitValue: priorSubmitValue + ) + else { + XCTFail( + "The Add submission tap did not start local work; " + + submissionDiagnostics(app, submit: submit) + ) + return nil + } + guard + waitForWorkToFinish( + app, + submit: submit, + priorSubmitValue: priorSubmitValue + ) + else { + XCTFail( + "The Add submission did not reach a terminal local state; " + + submissionDiagnostics(app, submit: submit) + ) + return nil + } + return submit.value as? String ?? "missing" + } + + @MainActor + private func assertUnverifiedDraft(_ app: XCUIApplication) -> Bool { + guard openDrafts(app) else { + XCTFail("The Drafts sheet did not open after the unavailable attempt") + return false + } + let mediaStatus = app.descendants(matching: .any).matching( + NSPredicate(format: "label CONTAINS '0 of 1 photos verified'") + ).firstMatch + let exists = mediaStatus.waitForExistence(timeout: 20) + XCTAssertTrue(exists) + if exists { + XCTAssertTrue( + mediaStatus.label == "0 of 1 photos verified" + || mediaStatus.label == "0 of 1 photos verified; 1 possible orphan", + "An unavailable upload did not preserve a bounded retry state; " + + "media_status.label=\(mediaStatus.label)" + ) + } + app.buttons["Done"].tap() + return exists + } + + @MainActor + private func waitForWorkToFinish( + _ app: XCUIApplication, + submit: XCUIElement, + priorSubmitValue: String? + ) -> Bool { + let progress = app.descendants(matching: .any)["radroots.add.progress"] + let finished = NSPredicate { _, _ in + let submitValue = submit.value as? String + return submit.exists + && !progress.exists + && submitValue != priorSubmitValue + && submitValue != "Working" + } + let expectation = XCTNSPredicateExpectation(predicate: finished, object: app) + return XCTWaiter.wait(for: [expectation], timeout: 180) == .completed + } + + @MainActor + private func tapSubmitUntilWorkStarts( + _ app: XCUIApplication, + submit: XCUIElement, + priorSubmitValue: String? + ) -> Bool { + let progress = app.descendants(matching: .any)["radroots.add.progress"] + let started = NSPredicate { _, _ in + let submitValue = submit.value as? String + return progress.exists + || submitValue != priorSubmitValue } - @MainActor - private func isUnobscured(_ element: XCUIElement, above obstruction: XCUIElement) -> Bool { - guard element.exists, element.isEnabled, element.isHittable, obstruction.exists else { - return false - } - let frame = element.frame - return frame.height > 0 - && frame.minY >= 0 - && frame.maxY <= obstruction.frame.minY - } - - @MainActor - private func submissionDiagnostics( - _ app: XCUIApplication, - submit: XCUIElement - ) -> String { - let progress = app.descendants(matching: .any)["radroots.add.progress"] - let status = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch - let statusLabel = status.exists ? status.label : "missing" - let tabBar = app.tabBars.firstMatch - return "submit.exists=\(submit.exists), submit.enabled=\(submit.isEnabled), " - + "submit.hittable=\(submit.isHittable), submit.value=\(String(describing: submit.value)), " - + "submit.frame=\(submit.frame), " - + "tab_bar_top=\(tabBar.exists ? tabBar.frame.minY : -1), " - + "progress.exists=\(progress.exists), status=\(statusLabel)" - } - - @MainActor - private func openDrafts(_ app: XCUIApplication) -> Bool { - let drafts = app.buttons["radroots.add.drafts"] - guard drafts.waitForExistence(timeout: 10) else { return false } - let sheet = app.descendants(matching: .any)["radroots.add.drafts.sheet"] - for _ in 0 ..< 3 { - drafts.tap() - if sheet.waitForExistence(timeout: 10) { - return true - } - } + for _ in 0..<3 { + submit.tap() + let expectation = XCTNSPredicateExpectation(predicate: started, object: app) + if XCTWaiter.wait(for: [expectation], timeout: 5) == .completed { + return true + } + guard waitUntilHittable(submit, timeout: 5), + isUnobscured(submit, above: app.tabBars.firstMatch) + else { return false + } } + return false + } - @MainActor - private func waitUntilHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let predicate = NSPredicate(format: "exists == true AND hittable == true") - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) - return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + @MainActor + private func isUnobscured(_ element: XCUIElement, above obstruction: XCUIElement) -> Bool { + guard element.exists, element.isEnabled, element.isHittable, obstruction.exists else { + return false } - - @MainActor - private func waitForValue( - _ element: XCUIElement, - value: String, - timeout: TimeInterval - ) -> Bool { - let predicate = NSPredicate { object, _ in - guard let object = object as? XCUIElement else { return false } - return object.exists && object.value as? String == value - } - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) - return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed - } - - private func writeBootstrapReceipt( - configuration: QualificationConfiguration, - publicKey: String - ) throws { - let receipt = BootstrapReceipt( - schema: "radroots-ios-remote-qualification-bootstrap-v1", - schemaVersion: 1, - runID: configuration.runID, - publicKey: publicKey, - relayURLs: configuration.relayURLs, - blossomOrigins: configuration.blossomOrigins, - privateKeyPresent: false, - interactiveAuthenticationRequired: false - ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(receipt) - let documents = try XCTUnwrap( - FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first - ) - try data.write( - to: documents.appendingPathComponent(Self.receiptName), - options: .atomic - ) - let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") - attachment.name = Self.receiptName - attachment.lifetime = .keepAlways - add(attachment) + let frame = element.frame + return frame.height > 0 + && frame.minY >= 0 + && frame.maxY <= obstruction.frame.minY + } + + @MainActor + private func submissionDiagnostics( + _ app: XCUIApplication, + submit: XCUIElement + ) -> String { + let progress = app.descendants(matching: .any)["radroots.add.progress"] + let status = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch + let statusLabel = status.exists ? status.label : "missing" + let tabBar = app.tabBars.firstMatch + return "submit.exists=\(submit.exists), submit.enabled=\(submit.isEnabled), " + + "submit.hittable=\(submit.isHittable), submit.value=\(String(describing: submit.value)), " + + "submit.frame=\(submit.frame), " + + "tab_bar_top=\(tabBar.exists ? tabBar.frame.minY : -1), " + + "progress.exists=\(progress.exists), status=\(statusLabel)" + } + + @MainActor + private func openDrafts(_ app: XCUIApplication) -> Bool { + let drafts = app.buttons["radroots.add.drafts"] + guard drafts.waitForExistence(timeout: 10) else { return false } + let sheet = app.descendants(matching: .any)["radroots.add.drafts.sheet"] + for _ in 0..<3 { + drafts.tap() + if sheet.waitForExistence(timeout: 10) { + return true + } } + return false + } + + @MainActor + private func waitUntilHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "exists == true AND hittable == true") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + @MainActor + private func focusForTextInput(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let focused = NSPredicate(format: "hasKeyboardFocus == true") + for _ in 0..<3 { + element.tap() + let expectation = XCTNSPredicateExpectation(predicate: focused, object: element) + if XCTWaiter.wait(for: [expectation], timeout: timeout / 3) == .completed { + return true + } + } + return false + } + + @MainActor + private func waitForValue( + _ element: XCUIElement, + value: String, + timeout: TimeInterval + ) -> Bool { + let predicate = NSPredicate { object, _ in + guard let object = object as? XCUIElement else { return false } + return object.exists && object.value as? String == value + } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + @MainActor + private func waitForLabel( + _ element: XCUIElement, + label: String, + timeout: TimeInterval + ) -> Bool { + let predicate = NSPredicate { object, _ in + guard let object = object as? XCUIElement else { return false } + return object.exists && object.label == label + } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + private func writeBootstrapReceipt( + configuration: QualificationConfiguration, + publicKey: String + ) throws { + let receipt = BootstrapReceipt( + schema: "radroots-ios-remote-qualification-bootstrap-v1", + schemaVersion: 1, + runID: configuration.runID, + publicKey: publicKey, + relayURLs: configuration.relayURLs, + blossomOrigins: configuration.blossomOrigins, + privateKeyPresent: false, + interactiveAuthenticationRequired: false + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(receipt) + let documents = try XCTUnwrap( + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + ) + try data.write( + to: documents.appendingPathComponent(Self.receiptName), + options: .atomic + ) + let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") + attachment.name = Self.receiptName + attachment.lifetime = .keepAlways + add(attachment) + } } private struct QualificationConfiguration { - static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE" - static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID" - static let relayURLsKey = "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS" - static let blossomOriginsKey = "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS" - static let mediaRelativePathKey = "RADROOTS_IOS_UI_TEST_MEDIA_RELATIVE_PATH" - - let runID: String - let relayURLs: [String] - let blossomOrigins: [String] - - var launchEnvironment: [String: String] { - [ - Self.enabledKey: "1", - Self.runIDKey: runID, - Self.relayURLsKey: relayURLs.joined(separator: ","), - Self.blossomOriginsKey: blossomOrigins.joined(separator: ","), - Self.mediaRelativePathKey: "qualification/input.png", - "RUST_BACKTRACE": "1", - ] - } - - static func environment( - _ values: [String: String] = ProcessInfo.processInfo.environment - ) throws -> Self { - let bundle = Bundle(for: RadrootsRemoteQualificationUITests.self) - let runIDValue = values[runIDKey] - ?? bundle.object(forInfoDictionaryKey: runIDKey) as? String - let relayValue = values[relayURLsKey] - ?? bundle.object(forInfoDictionaryKey: relayURLsKey) as? String - let blossomValue = values[blossomOriginsKey] - ?? bundle.object(forInfoDictionaryKey: blossomOriginsKey) as? String - if runIDValue?.isEmpty != false, blossomValue?.isEmpty != false { - throw XCTSkip("remote qualification inputs were not selected") - } - guard let runID = runIDValue, - runID.range(of: "^[a-z0-9][a-z0-9-]{6,62}[a-z0-9]$", options: .regularExpression) != nil, - let blossom = blossomValue, - !blossom.isEmpty - else { - throw QualificationError.missingEnvironment - } - return Self( - runID: runID, - relayURLs: separated(relayValue), - blossomOrigins: separated(blossom) - ) + static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE" + static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID" + static let relayURLsKey = "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS" + static let blossomOriginsKey = "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS" + static let fixtureControlKey = "RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL" + static let mediaRelativePathKey = "RADROOTS_IOS_UI_TEST_MEDIA_RELATIVE_PATH" + static let networkProfileKey = "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE" + + let runID: String + let relayURLs: [String] + let blossomOrigins: [String] + let fixtureControl: String? + let networkProfile: String + + init( + runID: String, + relayURLs: [String], + blossomOrigins: [String], + fixtureControl: String? = nil, + networkProfile: String = "public" + ) { + self.runID = runID + self.relayURLs = relayURLs + self.blossomOrigins = blossomOrigins + self.fixtureControl = fixtureControl + self.networkProfile = networkProfile + } + + var launchEnvironment: [String: String] { + [ + Self.enabledKey: "1", + Self.runIDKey: runID, + Self.relayURLsKey: relayURLs.joined(separator: ","), + Self.blossomOriginsKey: blossomOrigins.joined(separator: ","), + Self.mediaRelativePathKey: "qualification/input.png", + Self.networkProfileKey: networkProfile, + "RUST_BACKTRACE": "1", + ] + } + + static func environment( + _ values: [String: String] = ProcessInfo.processInfo.environment + ) throws -> Self { + let bundle = Bundle(for: RadrootsRemoteQualificationUITests.self) + let runIDValue = + values[runIDKey] + ?? bundle.object(forInfoDictionaryKey: runIDKey) as? String + let relayValue = + values[relayURLsKey] + ?? bundle.object(forInfoDictionaryKey: relayURLsKey) as? String + let blossomValue = + values[blossomOriginsKey] + ?? bundle.object(forInfoDictionaryKey: blossomOriginsKey) as? String + let fixtureControl = + values[fixtureControlKey] + ?? bundle.object(forInfoDictionaryKey: fixtureControlKey) as? String + let networkProfile = + values[networkProfileKey] + ?? bundle.object(forInfoDictionaryKey: networkProfileKey) as? String + if runIDValue?.isEmpty != false, blossomValue?.isEmpty != false { + throw XCTSkip("remote qualification inputs were not selected") } - - private static func separated(_ value: String?) -> [String] { - (value ?? "").split(separator: ",").map(String.init).filter { !$0.isEmpty } + guard let runID = runIDValue, + runID.range(of: "^[a-z0-9][a-z0-9-]{6,62}[a-z0-9]$", options: .regularExpression) != nil, + let blossom = blossomValue, + !blossom.isEmpty, + let networkProfile, + networkProfile == "public" || networkProfile == "simulator" + else { + throw QualificationError.missingEnvironment } + return Self( + runID: runID, + relayURLs: separated(relayValue), + blossomOrigins: separated(blossom), + fixtureControl: fixtureControl.flatMap { $0.isEmpty ? nil : $0 }, + networkProfile: networkProfile + ) + } + + private static func separated(_ value: String?) -> [String] { + (value ?? "").split(separator: ",").map(String.init).filter { !$0.isEmpty } + } } private struct BootstrapReceipt: Codable { - let schema: String - let schemaVersion: UInt16 - let runID: String - let publicKey: String - let relayURLs: [String] - let blossomOrigins: [String] - let privateKeyPresent: Bool - let interactiveAuthenticationRequired: Bool + let schema: String + let schemaVersion: UInt16 + let runID: String + let publicKey: String + let relayURLs: [String] + let blossomOrigins: [String] + let privateKeyPresent: Bool + let interactiveAuthenticationRequired: Bool } private enum QualificationError: Error { - case missingEnvironment - case invalidPublicKey + case missingEnvironment + case invalidPublicKey + case missingProductSurface + case productSubmissionFailed } diff --git a/project.yml b/project.yml @@ -13,7 +13,7 @@ packages: path: . RadrootsKit: url: https://github.com/radrootslabs/apple_kit.git - revision: 86f5548e9e00c2b7ad3c1d6837bff8bc41bd0f24 + revision: e61386529422a6413a852f0233a196a88f4ded51 targets: Radroots: diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""Bounded loopback Nostr and Blossom fixture for iOS simulator tests.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import http.server +import json +import os +import signal +import socket +import socketserver +import struct +import threading +import time +from pathlib import Path +from typing import Any + +MAX_HTTP_BODY = 16 * 1024 * 1024 +MAX_WEBSOCKET_MESSAGE = 2 * 1024 * 1024 +MAX_EVENTS = 256 +MAX_BLOBS = 16 + + +class FixtureState: + def __init__(self, evidence: Path, control: Path, blossom_port: int) -> None: + self._evidence = evidence + self.control = control + self.blossom_port = blossom_port + self._lock = threading.Lock() + self._events: dict[str, dict[str, Any]] = {} + self._blobs: dict[str, tuple[bytes, str]] = {} + self._upload_attempts = 0 + self._accepted_uploads = 0 + self._retrievals = 0 + self._subscriptions = 0 + self._write_evidence() + + def publish(self, event: dict[str, Any]) -> bool: + if not valid_nostr_event(event): + return False + event_id = event["id"] + with self._lock: + if event_id not in self._events and len(self._events) >= MAX_EVENTS: + return False + self._events[event_id] = event + self._write_evidence_locked() + return True + + def query(self, filters: list[dict[str, Any]]) -> list[dict[str, Any]]: + with self._lock: + self._subscriptions += 1 + events = list(self._events.values()) + self._write_evidence_locked() + selected = [event for event in events if any(matches(event, item) for item in filters)] + selected.sort(key=lambda item: (item.get("created_at", 0), item.get("id", ""))) + limits = [ + item["limit"] + for item in filters + if isinstance(item, dict) and type(item.get("limit")) is int + ] + count = min(max(max(limits, default=len(selected)), 0), MAX_EVENTS) + return selected[-count:] if count else [] + + def upload(self, body: bytes, media_type: str, expected_hash: str) -> tuple[bool, dict[str, Any]]: + digest = hashlib.sha256(body).hexdigest() + with self._lock: + self._upload_attempts += 1 + allowed = self.control.is_file() + capacity = digest in self._blobs or len(self._blobs) < MAX_BLOBS + if allowed and capacity and digest == expected_hash: + self._blobs[digest] = (body, media_type) + self._accepted_uploads += 1 + self._write_evidence_locked() + descriptor = { + "url": f"http://127.0.0.1:{self.blossom_port}/{digest}.png", + "sha256": digest, + "size": len(body), + "type": media_type, + "uploaded": int(time.time()), + } + return allowed and capacity and digest == expected_hash, descriptor + + def retrieve(self, digest: str) -> tuple[bytes, str] | None: + with self._lock: + value = self._blobs.get(digest) + if value is not None: + self._retrievals += 1 + self._write_evidence_locked() + return value + + def _write_evidence(self) -> None: + with self._lock: + self._write_evidence_locked() + + def _write_evidence_locked(self) -> None: + payload = { + "schema": "radroots-ios-local-social-fixture-evidence-v1", + "schema_version": 1, + "accepted_events": len(self._events), + "event_kinds": sorted( + event["kind"] + for event in self._events.values() + if isinstance(event.get("kind"), int) + ), + "subscriptions": self._subscriptions, + "upload_attempts": self._upload_attempts, + "accepted_uploads": self._accepted_uploads, + "retrievals": self._retrievals, + } + temporary = self._evidence.with_suffix(".tmp") + temporary.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8") + os.replace(temporary, self._evidence) + + +def matches(event: dict[str, Any], item: dict[str, Any]) -> bool: + if not isinstance(item, dict): + return False + if isinstance(item.get("ids"), list) and event.get("id") not in item["ids"]: + return False + if isinstance(item.get("authors"), list) and event.get("pubkey") not in item["authors"]: + return False + if isinstance(item.get("kinds"), list) and event.get("kind") not in item["kinds"]: + return False + created_at = event.get("created_at") + if not isinstance(created_at, int): + return False + if isinstance(item.get("since"), int) and created_at < item["since"]: + return False + if isinstance(item.get("until"), int) and created_at > item["until"]: + return False + tags = event.get("tags") + if not isinstance(tags, list): + return False + for key, expected in item.items(): + if not key.startswith("#") or not isinstance(expected, list): + continue + name = key[1:] + if not any( + isinstance(tag, list) + and len(tag) >= 2 + and tag[0] == name + and tag[1] in expected + for tag in tags + ): + return False + return True + + +def valid_nostr_event(event: dict[str, Any]) -> bool: + if set(event) != {"id", "pubkey", "created_at", "kind", "tags", "content", "sig"}: + return False + if not lowercase_hex(event["id"], 64) or not lowercase_hex(event["pubkey"], 64): + return False + if not lowercase_hex(event["sig"], 128): + return False + if type(event["created_at"]) is not int or event["created_at"] < 0: + return False + if type(event["kind"]) is not int or not 0 <= event["kind"] <= 0xFFFF_FFFF: + return False + if not isinstance(event["content"], str) or not isinstance(event["tags"], list): + return False + if not all( + isinstance(tag, list) and all(isinstance(value, str) for value in tag) + for tag in event["tags"] + ): + return False + preimage = json.dumps( + [ + 0, + event["pubkey"], + event["created_at"], + event["kind"], + event["tags"], + event["content"], + ], + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(preimage).hexdigest() == event["id"] + + +def lowercase_hex(value: Any, length: int) -> bool: + return ( + isinstance(value, str) + and len(value) == length + and all(character in "0123456789abcdef" for character in value) + ) + + +class ReusableThreadingServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +class RelayHandler(socketserver.BaseRequestHandler): + state: FixtureState + + def handle(self) -> None: + self.request.settimeout(15) + headers = read_until(self.request, b"\r\n\r\n", 16 * 1024) + lines = headers.decode("ascii").split("\r\n") + if not lines or lines[0] != "GET / HTTP/1.1": + return + fields = {} + for line in lines[1:]: + if ":" in line: + key, value = line.split(":", 1) + fields[key.lower()] = value.strip() + key = fields.get("sec-websocket-key") + if not key or fields.get("upgrade", "").lower() != "websocket": + return + accept = base64.b64encode( + hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest() + ).decode() + self.request.sendall( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n\r\n" + ).encode("ascii") + ) + while True: + frame = read_frame(self.request) + if frame is None: + return + opcode, payload = frame + if opcode == 0x8: + return + if opcode == 0x9: + send_frame(self.request, 0xA, payload) + continue + if opcode != 0x1: + continue + try: + message = json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError): + continue + if not isinstance(message, list) or not message: + continue + if message[0] == "EVENT" and len(message) == 2 and isinstance(message[1], dict): + event_id = message[1].get("id", "") + accepted = self.state.publish(message[1]) + send_json(self.request, ["OK", event_id, accepted, "" if accepted else "invalid"]) + elif ( + message[0] == "REQ" + and len(message) >= 3 + and isinstance(message[1], str) + ): + subscription = message[1] + filters = [value for value in message[2:] if isinstance(value, dict)] + for event in self.state.query(filters): + send_json(self.request, ["EVENT", subscription, event]) + send_json(self.request, ["EOSE", subscription]) + + +def read_until(stream: socket.socket, marker: bytes, maximum: int) -> bytes: + value = bytearray() + while marker not in value: + chunk = stream.recv(4096) + if not chunk: + raise ConnectionError("socket closed") + value.extend(chunk) + if len(value) > maximum: + raise ValueError("request too large") + return bytes(value) + + +def read_exact(stream: socket.socket, length: int) -> bytes: + value = bytearray() + while len(value) < length: + chunk = stream.recv(length - len(value)) + if not chunk: + raise ConnectionError("socket closed") + value.extend(chunk) + return bytes(value) + + +def read_frame(stream: socket.socket) -> tuple[int, bytes] | None: + try: + first, second = read_exact(stream, 2) + except (ConnectionError, OSError, TimeoutError): + return None + if first & 0x80 == 0 or second & 0x80 == 0: + return None + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", read_exact(stream, 2))[0] + elif length == 127: + length = struct.unpack("!Q", read_exact(stream, 8))[0] + if length > MAX_WEBSOCKET_MESSAGE: + return None + mask = read_exact(stream, 4) + payload = bytearray(read_exact(stream, length)) + for index in range(length): + payload[index] ^= mask[index % 4] + return first & 0x0F, bytes(payload) + + +def send_frame(stream: socket.socket, opcode: int, payload: bytes) -> None: + length = len(payload) + header = bytearray([0x80 | opcode]) + if length < 126: + header.append(length) + elif length <= 0xFFFF: + header.append(126) + header.extend(struct.pack("!H", length)) + else: + header.append(127) + header.extend(struct.pack("!Q", length)) + stream.sendall(header + payload) + + +def send_json(stream: socket.socket, value: Any) -> None: + send_frame(stream, 0x1, json.dumps(value, separators=(",", ":")).encode()) + + +class BlossomHandler(http.server.BaseHTTPRequestHandler): + state: FixtureState + protocol_version = "HTTP/1.1" + + def do_PUT(self) -> None: # noqa: N802 + component = self.path.removeprefix("/") + if ( + not component.endswith(".png") + or len(component) != 68 + or any(character not in "0123456789abcdef" for character in component[:-4]) + ): + self.send_error(404) + return + try: + length = int(self.headers.get("Content-Length", "-1")) + except ValueError: + length = -1 + authorization = self.headers.get("Authorization", "") + expected_hash = component[:-4] + media_type = self.headers.get("Content-Type", "") + if ( + length < 1 + or length > MAX_HTTP_BODY + or not authorization.startswith("Nostr ") + or media_type != "image/png" + ): + self.send_error(400) + return + body = self.rfile.read(length) + accepted, descriptor = self.state.upload(body, media_type, expected_hash) + if not accepted: + self.respond(503, b'{"error":"fixture_retry_required"}', "application/json") + return + self.respond(200, json.dumps(descriptor, separators=(",", ":")).encode(), "application/json") + + def do_GET(self) -> None: # noqa: N802 + component = self.path.removeprefix("/") + if component.endswith(".png") and lowercase_hex(component[:-4], 64): + value = self.state.retrieve(component[:-4]) + if value is not None: + self.respond(200, value[0], value[1]) + return + self.respond(404, b'{"error":"not_found"}', "application/json") + + def respond(self, status: int, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: + return + + +def serve(arguments: argparse.Namespace) -> int: + evidence = Path(arguments.evidence).resolve() + ready = Path(arguments.ready).resolve() + control = Path(arguments.control).resolve() + for path in (evidence, ready, control): + path.parent.mkdir(parents=True, exist_ok=True) + control.unlink(missing_ok=True) + ready.unlink(missing_ok=True) + state = FixtureState(evidence, control, arguments.blossom_port) + RelayHandler.state = state + BlossomHandler.state = state + relay = ReusableThreadingServer(("127.0.0.1", arguments.relay_port), RelayHandler) + blossom = http.server.ThreadingHTTPServer( + ("127.0.0.1", arguments.blossom_port), BlossomHandler + ) + threads = [ + threading.Thread(target=relay.serve_forever, daemon=True), + threading.Thread(target=blossom.serve_forever, daemon=True), + ] + for thread in threads: + thread.start() + ready.write_text( + json.dumps( + { + "schema": "radroots-ios-local-social-fixture-ready-v1", + "relay": f"ws://127.0.0.1:{arguments.relay_port}", + "blossom": f"http://127.0.0.1:{arguments.blossom_port}", + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + stopped = threading.Event() + + def stop(_signum: int, _frame: Any) -> None: + stopped.set() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + stopped.wait() + relay.shutdown() + blossom.shutdown() + relay.server_close() + blossom.server_close() + for thread in threads: + thread.join(timeout=5) + return 0 + + +def verify(arguments: argparse.Namespace) -> int: + payload = json.loads(Path(arguments.evidence).read_text(encoding="utf-8")) + if ( + payload.get("schema") != "radroots-ios-local-social-fixture-evidence-v1" + or payload.get("accepted_events", 0) < 5 + or payload.get("subscriptions", 0) < 1 + or payload.get("upload_attempts", 0) < 1 + or payload.get("accepted_uploads", 0) < 1 + or payload.get("retrievals", 0) < 1 + ): + raise SystemExit("local-social fixture evidence is incomplete") + print("local-social fixture evidence verified") + return 0 + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser() + commands = root.add_subparsers(dest="command", required=True) + serve_command = commands.add_parser("serve") + serve_command.add_argument("--relay-port", type=int, required=True) + serve_command.add_argument("--blossom-port", type=int, required=True) + serve_command.add_argument("--evidence", required=True) + serve_command.add_argument("--ready", required=True) + serve_command.add_argument("--control", required=True) + verify_command = commands.add_parser("verify") + verify_command.add_argument("--evidence", required=True) + return root + + +def main() -> int: + arguments = parser().parse_args() + if arguments.command == "serve": + return serve(arguments) + return verify(arguments) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -44,6 +44,7 @@ grep -Fq "NSPrivacyAccessedAPICategoryUserDefaults" \ grep -Fq "CA92.1" "$repo_root/Radroots/Resources/PrivacyInfo.xcprivacy" plutil -lint "$repo_root/Radroots/Resources/PrivacyInfo.xcprivacy" >/dev/null plutil -lint "$repo_root/Radroots/Info.plist" >/dev/null +plutil -lint "$repo_root/RadrootsUITests/Info.plist" >/dev/null grep -Fq '<key>NSCameraUsageDescription</key>' "$repo_root/Radroots/Info.plist" grep -Fq '<key>NSFaceIDUsageDescription</key>' "$repo_root/Radroots/Info.plist" @@ -75,6 +76,17 @@ grep -Fq 'RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = wss:$(SLASH)$(SLASH)radroots.org "$repo_root/Radroots/Config/Base.xcconfig" grep -Fq 'RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = https:$(SLASH)$(SLASH)blossom.radroots.org' \ "$repo_root/Radroots/Config/Base.xcconfig" +grep -Fq '<key>RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL</key>' \ + "$repo_root/RadrootsUITests/Info.plist" +grep -Fq '<key>RADROOTS_IOS_UI_TEST_NETWORK_PROFILE</key>' \ + "$repo_root/RadrootsUITests/Info.plist" +grep -Fq 'local-social-ui-test)' "$repo_root/scripts/xcode.sh" +grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialFiveFlowScenario' \ + "$repo_root/scripts/xcode.sh" +grep -Fq '#if DEBUG' "$repo_root/Radroots/App/RadrootsRemoteQualification.swift" +grep -Fq 'case "simulator": runtimeMode = "simulator"' \ + "$repo_root/Radroots/App/RadrootsRemoteQualification.swift" +test -f "$repo_root/scripts/local-social-fixture.py" if rg -n \ 'AsyncImage|URLSession\.shared|Data\(contentsOf:[[:space:]]*URL' \ diff --git a/scripts/xcode.sh b/scripts/xcode.sh @@ -132,6 +132,93 @@ case "$operation" in "CODE_SIGN_IDENTITY=Apple Development" \ build ;; + local-social-ui-test) + destination=${2:?local-social-ui-test requires a simulator destination} + result_name=${3:?local-social-ui-test requires a result name} + qualification_run_id=${RADROOTS_IOS_UI_TEST_RUN_ID:?RADROOTS_IOS_UI_TEST_RUN_ID is required} + relay_port=${RADROOTS_IOS_LOCAL_SOCIAL_RELAY_PORT:-21000} + blossom_port=${RADROOTS_IOS_LOCAL_SOCIAL_BLOSSOM_PORT:-21100} + if [[ ! "$destination" =~ ^platform=iOS\ Simulator,id=[A-Fa-f0-9-]+$ ]]; then + echo "error: local-social-ui-test destination must be one exact simulator id" >&2 + exit 64 + fi + if [[ ! "$result_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then + echo "error: local-social-ui-test result name is invalid" >&2 + exit 64 + fi + if [[ ! "$qualification_run_id" =~ ^[a-z0-9][a-z0-9-]{6,62}[a-z0-9]$ ]]; then + echo "error: local-social-ui-test run id is invalid" >&2 + exit 64 + fi + for port in "$relay_port" "$blossom_port"; do + if [[ ! "$port" =~ ^[0-9]+$ ]] || ((port < 1024 || port > 65535)); then + echo "error: local-social-ui-test port is invalid" >&2 + exit 64 + fi + done + if [[ "$relay_port" == "$blossom_port" ]]; then + echo "error: local-social-ui-test ports must be distinct" >&2 + exit 64 + fi + : "${XCODE_RESULTS:?XCODE_RESULTS is required}" + mkdir -p "$XCODE_RESULTS" + result_bundle="$XCODE_RESULTS/$result_name.xcresult" + evidence="$XCODE_RESULTS/$result_name.fixture.json" + ready="$XCODE_RESULTS/$result_name.ready.json" + control="$XCODE_RESULTS/$result_name.enable-uploads" + if [[ -e "$result_bundle" || -e "$evidence" || -e "$ready" || -e "$control" ]]; then + echo "error: local-social-ui-test result already exists: $result_name" >&2 + exit 1 + fi + python3 scripts/local-social-fixture.py serve \ + --relay-port "$relay_port" \ + --blossom-port "$blossom_port" \ + --evidence "$evidence" \ + --ready "$ready" \ + --control "$control" & + fixture_pid=$! + cleanup_fixture() { + kill "$fixture_pid" 2>/dev/null || true + wait "$fixture_pid" 2>/dev/null || true + } + trap cleanup_fixture EXIT INT TERM + for _ in {1..100}; do + [[ -f "$ready" ]] && break + kill -0 "$fixture_pid" 2>/dev/null || { + echo "error: local-social fixture exited before readiness" >&2 + exit 1 + } + sleep 0.1 + done + if [[ ! -f "$ready" ]]; then + echo "error: local-social fixture readiness timed out" >&2 + exit 1 + fi + set +e + xcodebuild \ + -project Radroots.xcodeproj \ + -scheme Radroots \ + -configuration Debug \ + -destination "$destination" \ + "${output_args[@]}" \ + "${offline_args[@]}" \ + -resultBundlePath "$result_bundle" \ + "-only-testing:RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialFiveFlowScenario" \ + "RADROOTS_IOS_UI_TEST_RUN_ID=$qualification_run_id" \ + "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS=ws://127.0.0.1:$relay_port" \ + "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=http://127.0.0.1:$blossom_port" \ + "RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL=$control" \ + "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE=simulator" \ + test + test_status=$? + set -e + cleanup_fixture + trap - EXIT INT TERM + if ((test_status != 0)); then + exit "$test_status" + fi + python3 scripts/local-social-fixture.py verify --evidence "$evidence" + ;; remote-ui-test) destination=${2:?remote-ui-test requires a simulator destination} test_selector=${3:?remote-ui-test requires a RadrootsUITests selector} @@ -173,6 +260,7 @@ case "$operation" in "RADROOTS_IOS_UI_TEST_RUN_ID=$qualification_run_id" \ "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS=$relay_urls" \ "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=$blossom_origins" \ + "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE=public" \ test ;; physical-ui-build|physical-ui-test) @@ -237,7 +325,8 @@ case "$operation" in "CODE_SIGN_IDENTITY=Apple Development" \ "RADROOTS_IOS_UI_TEST_RUN_ID=$qualification_run_id" \ "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS=$relay_urls" \ - "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=$blossom_origins" + "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=$blossom_origins" \ + "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE=public" ) if [[ "$operation" == "physical-ui-build" ]]; then exec xcodebuild "${physical_args[@]}" build-for-testing @@ -258,7 +347,7 @@ case "$operation" in test-without-building ;; *) - echo "usage: $0 {resolve|package-build|package-test|project-build|project-test|physical-app-build|remote-ui-test|physical-ui-build|physical-ui-test}" >&2 + echo "usage: $0 {resolve|package-build|package-test|project-build|project-test|local-social-ui-test|remote-ui-test|physical-ui-build|physical-ui-test}" >&2 exit 64 ;; esac