commit 0b50b11afa1ff933bcb45b8e49a61fb6b46ee289
parent 0e999d5c3ec19ebd3a609a6d16972cb232f201a6
Author: triesap <tyson@radroots.org>
Date: Fri, 2 Oct 2026 10:00:01 +0000
ios: qualify current product accessibility
- Keep native forms and recovery actions readable at maximum text
- Preserve focus, motion preferences and explicit Save acknowledgments
- Verify photo description editing and durable loopback recovery
- Require every default accessibility finding to be resolved
Diffstat:
36 files changed, 1330 insertions(+), 238 deletions(-)
diff --git a/Makefile b/Makefile
@@ -5,6 +5,7 @@ FFI_ROOT := TeraFFI
FFI_TARGET ?= aarch64-apple-ios
SIMULATOR_NAME ?= iPhone 17 Pro
SIMULATOR_DESTINATION := platform=iOS Simulator,name=$(SIMULATOR_NAME)
+UI_TEST_SELECTOR ?=
.NOTPARALLEL:
@@ -89,7 +90,7 @@ unit-test: artifact-check package-contract-check project
cargo extbuild run -- scripts/xcode.sh project-test '$(SIMULATOR_DESTINATION)' TeraTests
ui-test: artifact-check package-contract-check project
- cargo extbuild run -- scripts/xcode.sh project-test '$(SIMULATOR_DESTINATION)' TeraUITests
+ cargo extbuild run -- scripts/xcode.sh project-test '$(SIMULATOR_DESTINATION)' TeraUITests '$(UI_TEST_SELECTOR)'
api-snapshot-write: package-build
cargo extbuild run -- scripts/app-api-snapshot.sh write
diff --git a/Package.swift b/Package.swift
@@ -46,6 +46,9 @@ let package = Package(
"tera.xcconfig",
],
sources: [
+ "Views/TeraIdentityImportInstruction.swift",
+ "Views/TeraReadableScrollEdges.swift",
+ "Views/TeraAccessibilityUITestSurface.swift",
"Runtime/TeraCameraAccess.swift",
"State/TeraMediaAccessStore.swift",
"Views/TeraMediaPermissionGuidance.swift",
diff --git a/README.md b/README.md
@@ -233,7 +233,14 @@ of the release lane.
`make package-contract-check` evaluates the Swift package manifest and parses
the TOML, plist, JSON, xcconfig, project-package, and lock inputs as structured,
bounded data. It also runs the locked fixture and verifier unit suites.
-The check evaluates Cargo's workspace graph and rejects members or local
+
+For focused UI iteration, `make ui-test UI_TEST_SELECTOR=TeraAccessibilityUITests/testFormAskAtLargestTextWithReduceMotionAndLocalSave`
+selects an existing owned class and method through the same artifact, package,
+project and output-routing checks. An omitted selector runs the full UI target.
+Unknown classes, methods, malformed selectors and extra arguments fail before
+Xcode starts. Focused runs do not replace complete required qualification.
+
+The package check evaluates Cargo's workspace graph and rejects members or local
dependencies outside this standalone repository, including implicit sibling
checkouts. The default Rust lane selects `tera_core`, `tera_ffi`, and
`tera_bindgen`; `tera_wasm` remains non-default. The resolved graph must use
diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj
@@ -38,6 +38,7 @@
36AB69B9F8DB9AADB8124795 /* TeraRecoveryCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156F07F1F2961FDF6539099A /* TeraRecoveryCompletionTests.swift */; };
38EF7FA8874E82B28110A232 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6967B3EAC4CF1A410B26DD1A /* PrivacyInfo.xcprivacy */; };
39B47EE698E9D7CC716A372F /* TeraRuntimeShutdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */; };
+ 39EC97809DDF74F40641AAE8 /* TeraAccessibilityAudit.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC5289B0C8C497EB52BF6DE1 /* TeraAccessibilityAudit.swift */; };
3D219E759CFB685936EB7068 /* TeraCalendarEditingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C48B4B5442B1A404C1A328 /* TeraCalendarEditingTests.swift */; };
3DED3C2547AC6F8D00913FE8 /* TeraStoppedUploadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D82A4781624F36870A6316 /* TeraStoppedUploadTests.swift */; };
41BD76CAA07F6071411F7AC1 /* TeraIdentityCancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F666094C5E385A2820F0FE82 /* TeraIdentityCancellationTests.swift */; };
@@ -49,6 +50,7 @@
4D24610694C7F80F7B5DA904 /* TeraPublicationTargetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 293D968D4E2ACA3BA414BF8D /* TeraPublicationTargetTests.swift */; };
4DDC1E0EC1194DD15B2B19A3 /* TeraSubmissionReservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4721B7A69E2FB989732A64D /* TeraSubmissionReservationTests.swift */; };
53651EBAF42E8698717CE936 /* TeraScopedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF28A9D711806A07A4A65EA8 /* TeraScopedStoreTests.swift */; };
+ 542968135071D02F02BD4614 /* TeraAccessibilityUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D159B8D5E2417578E1C88FC1 /* TeraAccessibilityUITests.swift */; };
5CA871667D410B9553BF2BF6 /* TeraCapacitySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF84D1BEAAAF218A58EE58D6 /* TeraCapacitySettingsTests.swift */; };
5DFD8053E97DED72B42BF528 /* TeraRecoveryInventoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F0FDC0D59BFCB5D7F928D5 /* TeraRecoveryInventoryTests.swift */; };
5F252ABA8CA48D5E3A7A0157 /* TeraMediaVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E1E87F9A11E85700F38635 /* TeraMediaVisibilityTests.swift */; };
@@ -280,7 +282,9 @@
C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceTaskTests.swift; sourceTree = "<group>"; };
C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraLifecycleTests.swift; sourceTree = "<group>"; };
C9849DFB0F2822630588D59F /* TeraNativeExecutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraNativeExecutionTests.swift; sourceTree = "<group>"; };
+ CC5289B0C8C497EB52BF6DE1 /* TeraAccessibilityAudit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAccessibilityAudit.swift; sourceTree = "<group>"; };
CF28A9D711806A07A4A65EA8 /* TeraScopedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedStoreTests.swift; sourceTree = "<group>"; };
+ D159B8D5E2417578E1C88FC1 /* TeraAccessibilityUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAccessibilityUITests.swift; sourceTree = "<group>"; };
D2C0B898B9BFDB82CF3B5EFB /* TeraAddMediaHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddMediaHarness.swift; sourceTree = "<group>"; };
D3C914B349DAE2EF66658229 /* TeraMediaFileFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMediaFileFixture.swift; sourceTree = "<group>"; };
D8ED2718D5E9E8980ADF3423 /* TeraAddSchemaFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddSchemaFixtures.swift; sourceTree = "<group>"; };
@@ -343,6 +347,8 @@
346963F5BDF916F6BC10D6F6 /* TeraUITests */ = {
isa = PBXGroup;
children = (
+ CC5289B0C8C497EB52BF6DE1 /* TeraAccessibilityAudit.swift */,
+ D159B8D5E2417578E1C88FC1 /* TeraAccessibilityUITests.swift */,
0D243145C276EBF17915AB4E /* TeraEditingProtectionUITests.swift */,
FD65DE234C7DD80CE3CB91EB /* TeraPhotoQualificationInput.swift */,
A5928D5CAC9F8EF904547DF8 /* TeraRecoveryUITests.swift */,
@@ -838,6 +844,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 39EC97809DDF74F40641AAE8 /* TeraAccessibilityAudit.swift in Sources */,
+ 542968135071D02F02BD4614 /* TeraAccessibilityUITests.swift in Sources */,
6F09A1AF6E764AC0136E8F74 /* TeraEditingProtectionUITests.swift in Sources */,
25CCFD77EFA43657C473ADDC /* TeraPhotoQualificationInput.swift in Sources */,
F8A87EA68BEF06EA7F0838D0 /* TeraRecoveryUITests.swift in Sources */,
diff --git a/Tera/App/AppEntry.swift b/Tera/App/AppEntry.swift
@@ -18,6 +18,20 @@ struct AppEntry: View {
@EnvironmentObject private var appModel: TeraAppModel
var body: some View {
+ #if DEBUG
+ if ProcessInfo.processInfo.environment["TERA_IOS_UI_TEST_SHELL"] == "1",
+ ProcessInfo.processInfo.environment["TERA_IOS_UI_TEST_ACCESSIBILITY"] != nil
+ {
+ TeraAccessibilityUITestSurface()
+ } else {
+ product
+ }
+ #else
+ product
+ #endif
+ }
+
+ private var product: some View {
Group {
if case let .running(snapshot) = appModel.phase {
TeraRootShell(
diff --git a/Tera/Views/RuntimeStatusView.swift b/Tera/Views/RuntimeStatusView.swift
@@ -11,20 +11,22 @@ struct RuntimeStatusView: View {
let recoverIdentity: () -> Void
let applyConfigurationReconfiguration: () -> Void
@State private var showsIdentityImport = false
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
NavigationStack {
- VStack(spacing: 20) {
- Spacer()
+ ScrollView {
+ VStack(spacing: 20) {
Image(systemName: symbolName)
.font(.system(size: 48, weight: .medium))
.foregroundStyle(symbolColor)
.accessibilityHidden(true)
Text(title)
.font(.title2.weight(.semibold))
+ .accessibilityAddTraits(.isHeader)
Text(detail)
.font(.body)
- .foregroundStyle(.secondary)
+ .foregroundStyle(.primary)
.multilineTextAlignment(.center)
if case let .failed(failure) = phase {
Text("Error code \(failure.code)")
@@ -60,37 +62,55 @@ struct RuntimeStatusView: View {
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("tera.runtime.recheck")
}
- Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .padding(24)
}
- .padding(24)
.navigationTitle("Tera")
+ .navigationBarTitleDisplayMode(dynamicTypeSize.isAccessibilitySize ? .inline : .automatic)
+ .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize)
+ .tint(.primary)
+ .buttonBorderShape(.roundedRectangle(radius: 12))
}
.sheet(isPresented: $showsIdentityImport) {
NavigationStack {
- VStack(alignment: .leading, spacing: 16) {
- Text("Enter an nsec or 64-character secret key. It is transferred directly to Apple custody and is never stored in view state.")
- .font(.footnote)
- .foregroundStyle(.secondary)
+ Form {
+ Section {
+ importInstruction("Enter an nsec or 64-character secret key.")
+ importInstruction("It is transferred directly to Apple custody.")
+ importInstruction("Secret input is never stored in view state.")
+ }
+ Section {
TeraSecureIdentityImportField { material in
showsIdentityImport = false
importIdentity(material)
}
- Spacer()
+ }
}
- .padding()
.navigationTitle("Import identity")
+ .navigationBarTitleDisplayMode(.inline)
+ .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") { showsIdentityImport = false }
+ Button { showsIdentityImport = false } label: {
+ Label("Cancel", systemImage: "xmark")
+ .labelStyle(.iconOnly)
+ .frame(minWidth: 44, minHeight: 44)
+ }
}
}
}
- .presentationDetents([.medium])
+ .tint(.primary)
+ .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium, .large])
.interactiveDismissDisabled()
}
.accessibilityIdentifier("radroots.runtime.status")
}
+ private func importInstruction(_ text: String) -> some View {
+ TeraIdentityImportInstruction(text: text)
+ }
+
private var symbolName: String {
switch phase {
case .starting: "leaf"
@@ -165,21 +185,25 @@ struct RuntimeStatusView: View {
struct TeraSecureIdentityImportField: UIViewRepresentable {
let submit: @MainActor (RadrootsIdentitySecretMaterial) -> Void
+ @State private var errorMessage: String?
func makeCoordinator() -> Coordinator {
- Coordinator(submit: submit)
+ Coordinator(errorChanged: { errorMessage = $0 }, submit: submit)
}
func makeUIView(context: Context) -> UIView {
let field = UITextField()
field.borderStyle = .roundedRect
+ field.font = .preferredFont(forTextStyle: .body)
+ field.adjustsFontForContentSizeCategory = true
field.isSecureTextEntry = true
field.textContentType = .password
field.autocapitalizationType = .none
field.autocorrectionType = .no
field.spellCheckingType = .no
field.returnKeyType = .done
- field.placeholder = "nsec1… or secret hex"
+ field.placeholder = "Secret key"
+ field.accessibilityLabel = "Secret identity key"
field.accessibilityIdentifier = "radroots.identity.import.secret"
field.delegate = context.coordinator
@@ -187,11 +211,13 @@ struct TeraSecureIdentityImportField: UIViewRepresentable {
var configuration = UIButton.Configuration.filled()
configuration.title = "Import securely"
button.configuration = configuration
+ button.titleLabel?.numberOfLines = 0
button.accessibilityIdentifier = "radroots.identity.import.submit"
button.addTarget(context.coordinator, action: #selector(Coordinator.submitIdentity), for: .touchUpInside)
let error = UILabel()
error.font = .preferredFont(forTextStyle: .footnote)
+ error.adjustsFontForContentSizeCategory = true
error.textColor = .secondaryLabel
error.numberOfLines = 0
error.accessibilityIdentifier = "radroots.identity.import.error"
@@ -206,13 +232,28 @@ struct TeraSecureIdentityImportField: UIViewRepresentable {
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
stack.topAnchor.constraint(equalTo: container.topAnchor),
+ stack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ field.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
+ button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
])
context.coordinator.field = field
context.coordinator.errorLabel = error
return container
}
- func updateUIView(_: UIView, context _: Context) {}
+ func updateUIView(_ uiView: UIView, context: Context) {
+ context.coordinator.errorLabel?.text = errorMessage
+ uiView.invalidateIntrinsicContentSize()
+ uiView.setNeedsLayout()
+ }
+
+ func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context _: Context) -> CGSize? {
+ uiView.systemLayoutSizeFitting(
+ CGSize(width: proposal.width ?? 320, height: 0),
+ withHorizontalFittingPriority: .required,
+ verticalFittingPriority: .fittingSizeLevel
+ )
+ }
static func dismantleUIView(_: UIView, coordinator: Coordinator) {
coordinator.field?.text = nil
@@ -228,8 +269,18 @@ struct TeraSecureIdentityImportField: UIViewRepresentable {
weak var field: UITextField?
weak var errorLabel: UILabel?
private let submit: @MainActor (RadrootsIdentitySecretMaterial) -> Void
+ private let announce: @MainActor (String) -> Void
+ private let errorChanged: @MainActor (String?) -> Void
- init(submit: @escaping @MainActor (RadrootsIdentitySecretMaterial) -> Void) {
+ init(
+ announce: @escaping @MainActor (String) -> Void = {
+ UIAccessibility.post(notification: .announcement, argument: $0)
+ },
+ errorChanged: @escaping @MainActor (String?) -> Void = { _ in },
+ submit: @escaping @MainActor (RadrootsIdentitySecretMaterial) -> Void
+ ) {
+ self.announce = announce
+ self.errorChanged = errorChanged
self.submit = submit
}
@@ -245,10 +296,14 @@ struct TeraSecureIdentityImportField: UIViewRepresentable {
do {
let material = try RadrootsIdentitySecretMaterial(importText: input)
errorLabel?.text = nil
+ errorChanged(nil)
submit(material)
} catch {
- errorLabel?.text = "Enter a valid Nostr secret key."
+ let message = "Enter a valid Nostr secret key."
+ errorLabel?.text = message
+ errorChanged(message)
field.becomeFirstResponder()
+ announce(message)
}
}
}
diff --git a/Tera/Views/TeraAccessibilityUITestSurface.swift b/Tera/Views/TeraAccessibilityUITestSurface.swift
@@ -0,0 +1,131 @@
+#if DEBUG
+ import RadrootsKit
+ import SwiftUI
+
+ /// Isolated presentation qualification: real stores and SQLite, no credentials
+ /// or external relays. The photo fixture cannot confirm durability or upload bytes.
+ struct TeraAccessibilityUITestSurface: View {
+ @StateObject private var fixture = TeraAccessibilityFixture()
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.dynamicTypeSize) private var textSize
+ private let scenario = ProcessInfo.processInfo.environment["TERA_IOS_UI_TEST_ACCESSIBILITY"]
+
+ var body: some View {
+ Group {
+ if scenario == "failure" || scenario == "identity" {
+ RuntimeStatusView(phase: scenario == "identity" ? .identityRequired : .failed(.local(
+ operation: "fixture", code: "ios.fixture.unavailable", safeMessage: "Local state is unavailable."
+ )), retry: { fixture.retried = true }, createIdentity: {}, importIdentity: { _ in },
+ unlockIdentity: {}, recoverIdentity: {}, applyConfigurationReconfiguration: {})
+ .overlay(alignment: .bottom) {
+ if fixture.retried {
+ Text("Local state checked").accessibilityIdentifier("tera.test.accessibility.retried")
+ }
+ }
+ } else if let stores = fixture.stores, let snapshot = fixture.snapshot {
+ TeraRootShell(snapshot: snapshot, stores: stores)
+ } else {
+ Text(fixture.failure ?? "Preparing isolated accessibility fixture")
+ }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("tera.test.accessibility.root")
+ .accessibilityValue(Text(verbatim: "text=\(textSize); reduceMotion=\(reduceMotion)"))
+ .task { await fixture.start() }
+ .overlay {
+ if scenario == "save-error", !fixture.hasPreparedSaveError {
+ Button("Prepare unconfirmed Save") {
+ Task { await fixture.prepareSaveError() }
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+ }
+
+ @MainActor
+ private final class TeraAccessibilityFixture: ObservableObject {
+ @Published var stores: TeraProductStores?
+ @Published var snapshot: TeraRuntimeSnapshot?
+ @Published var failure: String?
+ @Published var retried = false
+ @Published var hasPreparedSaveError = false
+ private let client = TeraRuntimeClient.production()
+
+ func start() async {
+ guard stores == nil, failure == nil else { return }
+ do {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent("tera-accessibility-" + UUID().uuidString)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let publicKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
+ let roots = try RadrootsAppleFileRoots(appIdentifier: "test.tera.accessibility", dataRoot: root,
+ cacheRoot: root, temporaryRoot: root)
+ let mobileStore = try RadrootsAppleMobileStore.prepare(roots: roots, publicKeyHex: publicKey,
+ protectedDataAvailability: .available)
+ let configuration = TeraRuntimeLaunchConfiguration(
+ applicationSupportDirectory: mobileStore.applicationSupportDirectory.path,
+ publicKeyHex: publicKey,
+ sourceGenerationHex: String(repeating: "04", count: 32), sourceGenerationCreatedAtUnixMilliseconds: 1_800_000_000_000,
+ protectedData: .available, networkProfile: .simulator, writableRelays: ["ws://127.0.0.1:19999"], blossom: nil,
+ app: .init(bundleIdentifier: "test.tera.accessibility", version: "1", buildNumber: "1", buildSHA: nil),
+ signerGeneration: "accessibility-fixture", signer: TeraAccessibilitySigner(), adoptBootstrapSettings: false
+ )
+ let snapshot = try await client.start(configuration: configuration)
+ let stores = TeraProductStores(runtimeClient: client, addMedia: TeraAccessibilityMedia())
+ stores.configure(snapshot: snapshot)
+ await stores.resume()
+ if let label = ProcessInfo.processInfo.environment["TERA_IOS_UI_TEST_FORM"],
+ let type = TeraAddCommandType.allCases.first(where: { $0.label == label })
+ {
+ stores.add.selectType(type)
+ }
+ self.snapshot = snapshot
+ self.stores = stores
+ } catch {
+ if case let TeraRuntimeClientError.startup(report) = error {
+ failure = "Accessibility fixture failed to start: " + report.code
+ } else {
+ failure = "Accessibility fixture failed to start."
+ }
+ }
+ }
+
+ func prepareSaveError() async {
+ guard let stores, !hasPreparedSaveError else { return }
+ await stores.add.importPhotos()
+ await stores.add.save()
+ hasPreparedSaveError = true
+ }
+ }
+
+ private struct TeraAccessibilitySigner: TeraRuntimeSigner {
+ func availability() async -> TeraRuntimeSignerAvailability {
+ .unavailable
+ }
+
+ func sign(_: TeraRuntimeSigningRequest) async -> TeraRuntimeSigningOutcome {
+ .unavailable
+ }
+ }
+
+ private struct TeraAccessibilityMedia: TeraAddMediaHandling {
+ func support() async throws -> TeraAddMediaSupport {
+ .init(library: true, camera: false)
+ }
+
+ func importImages(limit: Int) async throws -> [TeraPreparedMedia] {
+ guard limit > 0 else { return [] }
+ return [.init(opaqueReference: "media:" + String(repeating: "0", count: 64), remoteURL: nil,
+ sha256: String(repeating: "0", count: 64), mediaType: "image/png", byteSize: 4,
+ width: 2, height: 2, alt: "", preparedAtUnixSeconds: 1_800_000_000)]
+ }
+
+ func captureImage() async throws -> TeraPreparedMedia {
+ throw TeraComposerAcknowledgment.unconfirmed
+ }
+
+ func open(_: [TeraPreparedMedia]) async throws -> TeraOpenedMedia {
+ throw TeraComposerAcknowledgment.unconfirmed
+ }
+ }
+#endif
diff --git a/Tera/Views/TeraAddSaveStatus.swift b/Tera/Views/TeraAddSaveStatus.swift
@@ -7,19 +7,30 @@ struct TeraAddSaveStatus: View {
var mediaMessage: String?
let protection: TeraEditingProtection
@ObservedObject var repairs: TeraNativeRepairStore
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
Section {
Text(state.label)
.foregroundStyle(.secondary)
+ .id("tera.add.save-state")
.accessibilityIdentifier("tera.add.save-state")
if let mediaMessage {
Text(mediaMessage).accessibilityIdentifier("tera.add.media-recovery")
}
if let message {
- Label(message, systemImage: symbol)
+ HStack(alignment: .firstTextBaseline) {
+ if !dynamicTypeSize.isAccessibilitySize {
+ Image(systemName: symbol).accessibilityHidden(true)
+ }
+ Text(message)
+ .id("tera.add.operation-status")
+ .accessibilityIdentifier("radroots.add.status")
+ }
+ .accessibilityElement(children: .contain)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(nil)
.foregroundStyle(.secondary)
- .accessibilityIdentifier("radroots.add.status")
}
}
TeraEditingProtectionActions(protection: protection)
diff --git a/Tera/Views/TeraAddView.swift b/Tera/Views/TeraAddView.swift
@@ -15,10 +15,12 @@ struct TeraAddView: View {
@ObservedObject var store: TeraAddStore
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
+ @ScaledMetric(relativeTo: .body) private var editorMinimumHeight: CGFloat = 110
@State private var showsDrafts = false
@FocusState private var focusedField: FocusedField?
var body: some View {
+ ScrollViewReader { proxy in
Form {
switch store.state {
case .idle, .loading:
@@ -69,7 +71,13 @@ struct TeraAddView: View {
}
Section {
- Button("Save draft") { Task { await store.save() } }
+ Button("Save draft") {
+ Task {
+ await store.save()
+ proxy.scrollTo("tera.add.save-state", anchor: .top)
+ UIAccessibility.post(notification: .announcement, argument: store.composerState.label)
+ }
+ }
.buttonStyle(.borderedProminent)
.tint(.primary)
.disabled(!store.canSave)
@@ -82,30 +90,28 @@ struct TeraAddView: View {
.disabled(store.isWorking)
.accessibilityIdentifier("radroots.add.cancel")
}
- Text(
- "Submit saves an immutable local snapshot first. If signing, media, or a relay is unavailable, the saved operation remains available to retry."
- )
- .font(.footnote)
- .foregroundStyle(.primary)
+ Text("Submit saves an immutable local snapshot first.")
+ .foregroundStyle(.primary)
+ .id("tera.add.submit-capture-disclosure")
+ Text("Signing, media, or a relay may be unavailable.")
+ .foregroundStyle(.primary)
+ .id("tera.add.submit-effects-disclosure")
+ Text("The saved operation remains available to retry.")
+ .foregroundStyle(.primary)
+ .id("tera.add.submit-retry-disclosure")
- if dynamicTypeSize.isAccessibilitySize {
- submitButton
- }
+ submitButton
}
}
+ // Rebuild native row measurements when typography changes, including
+ // while this tab is hidden. Editing remains owned by the existing store.
+ .id(dynamicTypeSize)
.headerProminence(.increased)
.tint(.primary)
+ .buttonBorderShape(.roundedRectangle(radius: 12))
+ .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize)
.accessibilityIdentifier("radroots.add.root")
.accessibilityValue(store.isWorking ? "Working" : "Ready")
- .safeAreaInset(edge: .bottom, spacing: 0) {
- if !dynamicTypeSize.isAccessibilitySize {
- submitButton
- .padding(.horizontal)
- .padding(.vertical, 8)
- .padding(.bottom, 72)
- .background(.bar)
- }
- }
.disabled(store.isWorking)
.overlay {
if store.isWorking {
@@ -116,11 +122,14 @@ struct TeraAddView: View {
}
}
.navigationTitle("Add")
+ .navigationBarTitleDisplayMode(dynamicTypeSize.isAccessibilitySize ? .inline : .automatic)
.toolbarBackground(Color(uiColor: .systemBackground), for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
- Button("Drafts") { showsDrafts = true }
+ Button { showsDrafts = true } label: {
+ Label("Drafts", systemImage: "tray.full").labelStyle(.iconOnly)
+ }
.accessibilityIdentifier("radroots.add.drafts")
}
ToolbarItem(placement: .topBarTrailing) {
@@ -142,6 +151,7 @@ struct TeraAddView: View {
TeraDraftsSheet(store: store)
}
.task { await store.start() }
+ }
}
}
@@ -233,9 +243,11 @@ extension TeraAddView {
text: Binding(
get: { media.alt },
set: { store.updateMediaAlt(id: media.id, alt: $0) }
- )
+ ), axis: .vertical
)
+ .lineLimit(2 ... 6)
.focused($focusedField, equals: .media(media.id))
+ .accessibilityLabel("Describe this photo")
.accessibilityIdentifier("radroots.add.media.alt")
Button("Remove photo", role: .destructive) { store.removeMedia(id: media.id) }
}
@@ -296,7 +308,7 @@ extension TeraAddView {
private func contentEditor(prompt: LocalizedStringKey) -> some View {
ZStack(alignment: .topLeading) {
TextEditor(text: required(\.content))
- .frame(minHeight: 110)
+ .frame(minHeight: editorMinimumHeight)
.focused($focusedField, equals: .content)
.accessibilityLabel(Text(prompt))
.accessibilityIdentifier("radroots.add.content")
diff --git a/Tera/Views/TeraIdentityImportInstruction.swift b/Tera/Views/TeraIdentityImportInstruction.swift
@@ -0,0 +1,29 @@
+import SwiftUI
+import UIKit
+
+/// An intrinsically sized, wrapping instruction in the secure import form.
+struct TeraIdentityImportInstruction: UIViewRepresentable {
+ let text: String
+
+ func makeUIView(context _: Context) -> UILabel {
+ let label = UILabel()
+ label.font = .preferredFont(forTextStyle: .body)
+ label.adjustsFontForContentSizeCategory = true
+ label.textColor = .label
+ label.numberOfLines = 0
+ label.lineBreakMode = .byWordWrapping
+ label.setContentCompressionResistancePriority(.required, for: .vertical)
+ label.isAccessibilityElement = true
+ return label
+ }
+
+ func updateUIView(_ label: UILabel, context _: Context) {
+ label.text = text
+ }
+
+ func sizeThatFits(_ proposal: ProposedViewSize, uiView: UILabel, context _: Context) -> CGSize? {
+ guard let width = proposal.width, width.isFinite, width > 0 else { return nil }
+ let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
+ return CGSize(width: width, height: ceil(size.height))
+ }
+}
diff --git a/Tera/Views/TeraMediaPermissionGuidance.swift b/Tera/Views/TeraMediaPermissionGuidance.swift
@@ -7,22 +7,20 @@ struct TeraMediaPermissionGuidance: View {
@Environment(\.openURL) private var openURL
var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- Text(support.cameraAccess.guidance)
- Text(support.library
- ? "You can select photos from Library without full photo-library access, or keep editing and save without adding media."
- : "Photo selection is unavailable. You can keep editing and save without adding media.")
- if support.cameraAccess == .denied {
- Button("Open camera settings") {
- if let url = URL(string: UIApplication.openSettingsURLString) {
- openURL(url)
- }
+ Text(support.cameraAccess.guidance)
+ Text(support.library
+ ? "You can select photos from Library without full photo-library access."
+ : "Photo selection is unavailable.")
+ Text("You can keep editing and save without adding media.")
+ if support.cameraAccess == .denied {
+ Button("Open camera settings") {
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ openURL(url)
}
- .accessibilityIdentifier("tera.add.camera.settings")
}
- Button("Check camera access again") { Task { await recheck() } }
- .accessibilityIdentifier("tera.add.camera.recheck")
+ .accessibilityIdentifier("tera.add.camera.settings")
}
- .accessibilityElement(children: .contain)
+ Button("Check camera access again") { Task { await recheck() } }
+ .accessibilityIdentifier("tera.add.camera.recheck")
}
}
diff --git a/Tera/Views/TeraPublicationDisclosure.swift b/Tera/Views/TeraPublicationDisclosure.swift
@@ -3,13 +3,21 @@ import SwiftUI
struct TeraPublicationDisclosure: View {
var body: some View {
Section("Public publication") {
- Text("Submit publishes to public Nostr relays. A local network helps you discover nearby posts; it is not a private group.")
+ Text("Submit publishes to public Nostr relays.")
.accessibilityIdentifier("tera.add.disclosure.public")
- Text("Photos are uploaded to a separate public photo service. Anyone who obtains their links may view or copy them. Prepared photos have embedded location and camera metadata removed, but visible details can still identify people and places.")
+ Text("A local network helps you discover nearby posts; it is not a private group.")
+ Text("Photos are uploaded to a separate public photo service.")
.accessibilityIdentifier("tera.add.disclosure.media")
- Text("Location text you enter is public when you submit. Include an exact address only if you intend to share it publicly. Tera does not request your device location; you can enter a less precise description or leave optional location blank.")
+ Text("Anyone who obtains their links may view or copy them.")
+ Text("Prepared photos have embedded location and camera metadata removed.")
+ Text("Visible details can still identify people and places.")
+ Text("Location text you enter is public when you submit.")
.accessibilityIdentifier("tera.add.disclosure.location")
- Text("Save draft keeps editing on this device. Submission and retraction cannot guarantee that remote copies will be removed.")
+ Text("Include an exact address only if you intend to share it publicly.")
+ Text("Tera does not request your device location.")
+ Text("You can enter a less precise description or leave optional location blank.")
+ Text("Save draft keeps editing on this device.")
+ Text("Submission and retraction cannot guarantee that remote copies will be removed.")
}
}
}
diff --git a/Tera/Views/TeraReadableScrollEdges.swift b/Tera/Views/TeraReadableScrollEdges.swift
@@ -0,0 +1,14 @@
+import SwiftUI
+
+extension View {
+ @ViewBuilder
+ func teraReadableScrollEdges(_ enabled: Bool) -> some View {
+ // At accessibility sizes, the iOS 26 edge fade can obscure several lines
+ // of otherwise visible instructions. Keep text opaque up to the bars.
+ if #available(iOS 26.0, *) {
+ scrollEdgeEffectHidden(enabled, for: .all)
+ } else {
+ self
+ }
+ }
+}
diff --git a/Tera/Views/TeraSupportingViews.swift b/Tera/Views/TeraSupportingViews.swift
@@ -102,6 +102,7 @@ struct TeraMeSheet: View {
let revise: (TeraTodayCard) -> Void
let retract: (TeraTodayCard) -> Void
@Environment(\.dismiss) private var dismiss
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var showsDrafts = false
var body: some View {
@@ -123,6 +124,8 @@ struct TeraMeSheet: View {
}
}
.navigationTitle("Me")
+ .navigationBarTitleDisplayMode(dynamicTypeSize.isAccessibilitySize ? .inline : .automatic)
+ .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
@@ -136,7 +139,7 @@ struct TeraMeSheet: View {
store.configure(context: context)
await store.start()
}
- .presentationDetents([.medium, .large])
+ .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium, .large])
.accessibilityIdentifier("radroots.support.me.sheet")
}
diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json
@@ -92,19 +92,19 @@
"sha256": "d4303a4d980db72af18ee7746d1a03e1d2c5d80ccbc5d90183526b3ea10771dc"
},
{
- "bytes": 137212,
+ "bytes": 137462,
"path": "source/aarch64-apple-darwin.json",
- "sha256": "f825968510e9d46fe06f0be6ea7b8af45726020db78a141c85527ace8c0bd78d"
+ "sha256": "dd8206195e81d24da88e31c0affac0bc0a2094a2bdaf7891b2b19adf42b068d8"
},
{
- "bytes": 137056,
+ "bytes": 137306,
"path": "source/aarch64-apple-ios-sim.json",
- "sha256": "64c3ce643193068ea46a464fdc73b0638810afb8135731d3b7ebfa537e578e27"
+ "sha256": "535adf2ebbff90cc90c552e68da8a3df377da2560e45bfe3f476d07ae71e505c"
},
{
- "bytes": 137052,
+ "bytes": 137302,
"path": "source/aarch64-apple-ios.json",
- "sha256": "daf8f6ce3d8ef68ae5eea9ffa9955cc7e23434095740f37d55a90aec38362189"
+ "sha256": "d64a237cee01df2de9702276290859c3ccddde2838f8642c1d6ff1ffc0876476"
}
],
"language": "swift",
@@ -112,7 +112,7 @@
"schema": "radroots.artifact-manifest.v2",
"source": {
"repository": "https://github.com/radrootslabs/tera",
- "tree": "730344c702e337496a3979e05facd626d62535ab"
+ "tree": "59106fa182618636b8a257584168019d5ed3e9f8"
},
"source_records": {
"aarch64-apple-darwin": "source/aarch64-apple-darwin.json",
@@ -174,19 +174,19 @@
"sha256": "5d74060f97e52dad69ab7567c16d6b6b34acfab846f468450d89e2394a8a69d5"
},
{
- "bytes": 137212,
+ "bytes": 137462,
"path": "TeraFFI/source/aarch64-apple-darwin.json",
- "sha256": "f825968510e9d46fe06f0be6ea7b8af45726020db78a141c85527ace8c0bd78d"
+ "sha256": "dd8206195e81d24da88e31c0affac0bc0a2094a2bdaf7891b2b19adf42b068d8"
},
{
- "bytes": 137056,
+ "bytes": 137306,
"path": "TeraFFI/source/aarch64-apple-ios-sim.json",
- "sha256": "64c3ce643193068ea46a464fdc73b0638810afb8135731d3b7ebfa537e578e27"
+ "sha256": "535adf2ebbff90cc90c552e68da8a3df377da2560e45bfe3f476d07ae71e505c"
},
{
- "bytes": 137052,
+ "bytes": 137302,
"path": "TeraFFI/source/aarch64-apple-ios.json",
- "sha256": "daf8f6ce3d8ef68ae5eea9ffa9955cc7e23434095740f37d55a90aec38362189"
+ "sha256": "d64a237cee01df2de9702276290859c3ccddde2838f8642c1d6ff1ffc0876476"
}
],
"schema": "tera.installed-native-artifacts.v1"
diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock
@@ -1,7 +1,7 @@
schema = "tera.installed-source.v1"
repository = "https://github.com/radrootslabs/tera"
-source_tree = "730344c702e337496a3979e05facd626d62535ab"
-manifest_sha256 = "9b812a0aee55eec9eb733c52d445c17ee4796f68766b26b3bf7b4ea86f42959b"
+source_tree = "59106fa182618636b8a257584168019d5ed3e9f8"
+manifest_sha256 = "48e9d6e38543760fb2ef45b93e3305258bee7079e4097f52ca5869891cbd2354"
source_date_epoch = 1787871027
[foundation]
diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json
@@ -4,7 +4,7 @@
"iphoneos_sdk": "23F81a",
"iphonesimulator_sdk": "23F81a",
"swift": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)\nTarget: arm64-apple-macosx26.0",
- "swiftformat": "0.63.0",
+ "swiftformat": "0.63.1",
"xcode": "Xcode 26.6\nBuild version 17F113"
},
"config": "core/crates/tera_ffi/uniffi.toml",
@@ -513,10 +513,10 @@
"sha256": "0de901bfc37140b31a4fcbd0a813f9fc1a8c277c20410c0be3068e60d3446802"
},
"Makefile": {
- "bytes": 3934,
- "git_blob": "84eeeb038bc0262e65dd9100fc76a9132be01daa",
+ "bytes": 3976,
+ "git_blob": "91897a30656e65215cc73620b4d9eb8ede682ef5",
"mode": "100644",
- "sha256": "a772f1a6b7a004603c40b2f60b5f8f3914e7bff952cac21c49ea62363fa8d523"
+ "sha256": "0c7c343f51fe8503c29047ad3b93ff45582b5e5bff2768baa7883d9826c4be69"
},
"TeraFFI/Makefile": {
"bytes": 374,
@@ -2841,10 +2841,10 @@
"sha256": "1803572abdb2e22bedd275724edae5f09c523b132bc59086aacca0f5183905d0"
},
"scripts/swift-quality.sh": {
- "bytes": 2046,
- "git_blob": "cd574c9e901a596142e8785828f5cb33cc4f0f3e",
+ "bytes": 2080,
+ "git_blob": "65b81671c82fe4102e6599532b4d2d802cedf4a0",
"mode": "100755",
- "sha256": "d876131049757d241f7490c16992224110cdcd1c78a40a76a3b2d1a420cd2aeb"
+ "sha256": "15c98b26fdae1e6db8032b3201a8ab7e820800c721f0a4791ff374f81e6a004d"
},
"scripts/test_app_dependency_graph.py": {
"bytes": 4007,
@@ -2900,26 +2900,32 @@
"mode": "100644",
"sha256": "9073f8edaa80c71f7c1f718c4dcff954a30ac42218fbd19378803ecfb9b3b33f"
},
+ "scripts/test_xcode_selection.py": {
+ "bytes": 4745,
+ "git_blob": "b633afbccd765a106e2ad591881926a9add542ac",
+ "mode": "100644",
+ "sha256": "a917dfbc1d8ecac7d1539670651e4b468059bc77dfe31aeefbc6c6eb21bebbfb"
+ },
"scripts/verify-package-contract.sh": {
- "bytes": 1460,
- "git_blob": "335f5ec00f343bf5dc984a117f365273949d34e2",
+ "bytes": 1496,
+ "git_blob": "b21f0d5e48bcedd71460f7c0f4cf0c8f1070b27a",
"mode": "100755",
- "sha256": "eb8c7dd6eaa04fd72c876925d3caadeddfd90f2e18f67597df57064ac2fafa1e"
+ "sha256": "8ecb7b9fc1997e1cdb37d642d6dbfa59194bf9182218aceab470077f721fcd85"
},
"scripts/xcode.sh": {
- "bytes": 21183,
- "git_blob": "2cd94ce793f8dd4591bc7f410cfb06ca39ed7025",
+ "bytes": 22437,
+ "git_blob": "78cca176324eb56079598090626a01698273f707",
"mode": "100755",
- "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d"
+ "sha256": "4e174e14512c1b12994dea63f58bf39d234d96c3bf245eb5d7eaa0fcf2b4fd24"
},
"test-fixtures/legacy-identifiers.v1.json": {
- "bytes": 186011,
- "git_blob": "01800d808f9a03d7fbe4265b34975e19c5d48b10",
+ "bytes": 186460,
+ "git_blob": "ca81e3e1c1628650dceb01ebd2d3e6046c7a9b00",
"mode": "100644",
- "sha256": "4ff0635697621d4f3ba9754fd59e17b2556bb8f8f3d5b965cc2af9909f21ff7a"
+ "sha256": "7f5053f449d49ff9220e2ba57db5749c813dfe40f04965b0107ce7e49a15966c"
}
},
"policy": "staged_inputs",
- "tree": "730344c702e337496a3979e05facd626d62535ab"
+ "tree": "59106fa182618636b8a257584168019d5ed3e9f8"
}
}
diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json
@@ -4,7 +4,7 @@
"iphoneos_sdk": "23F81a",
"iphonesimulator_sdk": "23F81a",
"swift": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)\nTarget: arm64-apple-macosx26.0",
- "swiftformat": "0.63.0",
+ "swiftformat": "0.63.1",
"xcode": "Xcode 26.6\nBuild version 17F113"
},
"config": "core/crates/tera_ffi/uniffi.toml",
@@ -509,10 +509,10 @@
"sha256": "0de901bfc37140b31a4fcbd0a813f9fc1a8c277c20410c0be3068e60d3446802"
},
"Makefile": {
- "bytes": 3934,
- "git_blob": "84eeeb038bc0262e65dd9100fc76a9132be01daa",
+ "bytes": 3976,
+ "git_blob": "91897a30656e65215cc73620b4d9eb8ede682ef5",
"mode": "100644",
- "sha256": "a772f1a6b7a004603c40b2f60b5f8f3914e7bff952cac21c49ea62363fa8d523"
+ "sha256": "0c7c343f51fe8503c29047ad3b93ff45582b5e5bff2768baa7883d9826c4be69"
},
"TeraFFI/Makefile": {
"bytes": 374,
@@ -2837,10 +2837,10 @@
"sha256": "1803572abdb2e22bedd275724edae5f09c523b132bc59086aacca0f5183905d0"
},
"scripts/swift-quality.sh": {
- "bytes": 2046,
- "git_blob": "cd574c9e901a596142e8785828f5cb33cc4f0f3e",
+ "bytes": 2080,
+ "git_blob": "65b81671c82fe4102e6599532b4d2d802cedf4a0",
"mode": "100755",
- "sha256": "d876131049757d241f7490c16992224110cdcd1c78a40a76a3b2d1a420cd2aeb"
+ "sha256": "15c98b26fdae1e6db8032b3201a8ab7e820800c721f0a4791ff374f81e6a004d"
},
"scripts/test_app_dependency_graph.py": {
"bytes": 4007,
@@ -2896,26 +2896,32 @@
"mode": "100644",
"sha256": "9073f8edaa80c71f7c1f718c4dcff954a30ac42218fbd19378803ecfb9b3b33f"
},
+ "scripts/test_xcode_selection.py": {
+ "bytes": 4745,
+ "git_blob": "b633afbccd765a106e2ad591881926a9add542ac",
+ "mode": "100644",
+ "sha256": "a917dfbc1d8ecac7d1539670651e4b468059bc77dfe31aeefbc6c6eb21bebbfb"
+ },
"scripts/verify-package-contract.sh": {
- "bytes": 1460,
- "git_blob": "335f5ec00f343bf5dc984a117f365273949d34e2",
+ "bytes": 1496,
+ "git_blob": "b21f0d5e48bcedd71460f7c0f4cf0c8f1070b27a",
"mode": "100755",
- "sha256": "eb8c7dd6eaa04fd72c876925d3caadeddfd90f2e18f67597df57064ac2fafa1e"
+ "sha256": "8ecb7b9fc1997e1cdb37d642d6dbfa59194bf9182218aceab470077f721fcd85"
},
"scripts/xcode.sh": {
- "bytes": 21183,
- "git_blob": "2cd94ce793f8dd4591bc7f410cfb06ca39ed7025",
+ "bytes": 22437,
+ "git_blob": "78cca176324eb56079598090626a01698273f707",
"mode": "100755",
- "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d"
+ "sha256": "4e174e14512c1b12994dea63f58bf39d234d96c3bf245eb5d7eaa0fcf2b4fd24"
},
"test-fixtures/legacy-identifiers.v1.json": {
- "bytes": 186011,
- "git_blob": "01800d808f9a03d7fbe4265b34975e19c5d48b10",
+ "bytes": 186460,
+ "git_blob": "ca81e3e1c1628650dceb01ebd2d3e6046c7a9b00",
"mode": "100644",
- "sha256": "4ff0635697621d4f3ba9754fd59e17b2556bb8f8f3d5b965cc2af9909f21ff7a"
+ "sha256": "7f5053f449d49ff9220e2ba57db5749c813dfe40f04965b0107ce7e49a15966c"
}
},
"policy": "staged_inputs",
- "tree": "730344c702e337496a3979e05facd626d62535ab"
+ "tree": "59106fa182618636b8a257584168019d5ed3e9f8"
}
}
diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json
@@ -4,7 +4,7 @@
"iphoneos_sdk": "23F81a",
"iphonesimulator_sdk": "23F81a",
"swift": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)\nTarget: arm64-apple-macosx26.0",
- "swiftformat": "0.63.0",
+ "swiftformat": "0.63.1",
"xcode": "Xcode 26.6\nBuild version 17F113"
},
"config": "core/crates/tera_ffi/uniffi.toml",
@@ -509,10 +509,10 @@
"sha256": "0de901bfc37140b31a4fcbd0a813f9fc1a8c277c20410c0be3068e60d3446802"
},
"Makefile": {
- "bytes": 3934,
- "git_blob": "84eeeb038bc0262e65dd9100fc76a9132be01daa",
+ "bytes": 3976,
+ "git_blob": "91897a30656e65215cc73620b4d9eb8ede682ef5",
"mode": "100644",
- "sha256": "a772f1a6b7a004603c40b2f60b5f8f3914e7bff952cac21c49ea62363fa8d523"
+ "sha256": "0c7c343f51fe8503c29047ad3b93ff45582b5e5bff2768baa7883d9826c4be69"
},
"TeraFFI/Makefile": {
"bytes": 374,
@@ -2837,10 +2837,10 @@
"sha256": "1803572abdb2e22bedd275724edae5f09c523b132bc59086aacca0f5183905d0"
},
"scripts/swift-quality.sh": {
- "bytes": 2046,
- "git_blob": "cd574c9e901a596142e8785828f5cb33cc4f0f3e",
+ "bytes": 2080,
+ "git_blob": "65b81671c82fe4102e6599532b4d2d802cedf4a0",
"mode": "100755",
- "sha256": "d876131049757d241f7490c16992224110cdcd1c78a40a76a3b2d1a420cd2aeb"
+ "sha256": "15c98b26fdae1e6db8032b3201a8ab7e820800c721f0a4791ff374f81e6a004d"
},
"scripts/test_app_dependency_graph.py": {
"bytes": 4007,
@@ -2896,26 +2896,32 @@
"mode": "100644",
"sha256": "9073f8edaa80c71f7c1f718c4dcff954a30ac42218fbd19378803ecfb9b3b33f"
},
+ "scripts/test_xcode_selection.py": {
+ "bytes": 4745,
+ "git_blob": "b633afbccd765a106e2ad591881926a9add542ac",
+ "mode": "100644",
+ "sha256": "a917dfbc1d8ecac7d1539670651e4b468059bc77dfe31aeefbc6c6eb21bebbfb"
+ },
"scripts/verify-package-contract.sh": {
- "bytes": 1460,
- "git_blob": "335f5ec00f343bf5dc984a117f365273949d34e2",
+ "bytes": 1496,
+ "git_blob": "b21f0d5e48bcedd71460f7c0f4cf0c8f1070b27a",
"mode": "100755",
- "sha256": "eb8c7dd6eaa04fd72c876925d3caadeddfd90f2e18f67597df57064ac2fafa1e"
+ "sha256": "8ecb7b9fc1997e1cdb37d642d6dbfa59194bf9182218aceab470077f721fcd85"
},
"scripts/xcode.sh": {
- "bytes": 21183,
- "git_blob": "2cd94ce793f8dd4591bc7f410cfb06ca39ed7025",
+ "bytes": 22437,
+ "git_blob": "78cca176324eb56079598090626a01698273f707",
"mode": "100755",
- "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d"
+ "sha256": "4e174e14512c1b12994dea63f58bf39d234d96c3bf245eb5d7eaa0fcf2b4fd24"
},
"test-fixtures/legacy-identifiers.v1.json": {
- "bytes": 186011,
- "git_blob": "01800d808f9a03d7fbe4265b34975e19c5d48b10",
+ "bytes": 186460,
+ "git_blob": "ca81e3e1c1628650dceb01ebd2d3e6046c7a9b00",
"mode": "100644",
- "sha256": "4ff0635697621d4f3ba9754fd59e17b2556bb8f8f3d5b965cc2af9909f21ff7a"
+ "sha256": "7f5053f449d49ff9220e2ba57db5749c813dfe40f04965b0107ce7e49a15966c"
}
},
"policy": "staged_inputs",
- "tree": "730344c702e337496a3979e05facd626d62535ab"
+ "tree": "59106fa182618636b8a257584168019d5ed3e9f8"
}
}
diff --git a/TeraTests/TeraIdentityRecoveryTests.swift b/TeraTests/TeraIdentityRecoveryTests.swift
@@ -150,6 +150,36 @@ final class TeraIdentityRecoveryTests: XCTestCase {
}
@MainActor
+ func testImportValidationAnnouncesOnlySafeGuidanceAndClearsInput() {
+ var announcements: [String] = []
+ var errorChanges: [String?] = []
+ var submissions = 0
+ let coordinator = TeraSecureIdentityImportField.Coordinator(
+ announce: { announcements.append($0) }, errorChanged: { errorChanges.append($0) },
+ submit: { _ in submissions += 1 }
+ )
+ let field = UITextField()
+ let error = UILabel()
+ coordinator.field = field
+ coordinator.errorLabel = error
+ field.text = "invalid-private-input-canary"
+ coordinator.submitIdentity()
+ XCTAssertEqual(field.text ?? "", "")
+ XCTAssertEqual(announcements, ["Enter a valid Nostr secret key."])
+ XCTAssertEqual(error.text, announcements.first)
+ XCTAssertEqual(errorChanges, ["Enter a valid Nostr secret key."])
+ XCTAssertEqual(submissions, 0)
+
+ field.text = String(repeating: "01", count: 32)
+ coordinator.submitIdentity()
+ XCTAssertEqual(field.text ?? "", "")
+ XCTAssertNil(error.text)
+ XCTAssertEqual(errorChanges, ["Enter a valid Nostr secret key.", nil])
+ XCTAssertEqual(submissions, 1)
+ XCTAssertEqual(announcements.count, 1)
+ }
+
+ @MainActor
func testImportTeardownClearsSecretAndRejectsLateSubmit() {
var submissions = 0
let coordinator = TeraSecureIdentityImportField.Coordinator { _ in submissions += 1 }
diff --git a/TeraUITests/TeraAccessibilityAudit.swift b/TeraUITests/TeraAccessibilityAudit.swift
@@ -0,0 +1,338 @@
+import XCTest
+
+@MainActor
+final class TeraAccessibilityAudit {
+ private let test: XCTestCase
+ private var findings: [String] = []
+
+ init(test: XCTestCase) {
+ self.test = test
+ }
+
+ func run(_ app: XCUIApplication, dynamicType: Bool = false, includeContrast: Bool = true) throws -> [String] {
+ // Native scans can move focus and scroll position. Audit clipping before
+ // those scans and retain every finding, including unidentified ones.
+ var types: XCUIAccessibilityAuditType = [.elementDetection, .hitRegion,
+ .sufficientElementDescription, .trait]
+ if dynamicType {
+ types.insert(.dynamicType)
+ }
+ let before = XCTAttachment(string: app.debugDescription)
+ before.name = "Accessibility tree before audit"
+ before.lifetime = .keepAlways
+ test.add(before)
+ let screenshot = XCTAttachment(screenshot: app.screenshot())
+ screenshot.name = "Screen before audit"
+ screenshot.lifetime = .keepAlways
+ test.add(screenshot)
+ try scan(app, types: .textClipped)
+ if includeContrast {
+ try auditContrast(app)
+ }
+ try scan(app, types: types)
+ return findings
+ }
+
+ private func scan(_ app: XCUIApplication, types: XCUIAccessibilityAuditType) throws {
+ try app.performAccessibilityAudit(for: types) { issue in
+ let detail = XCTAttachment(string: "Audit type: \(issue.auditType.rawValue)\nAudit: \(issue.detailedDescription)\nElement: \(issue.element?.debugDescription ?? "unidentified")")
+ detail.lifetime = .keepAlways
+ self.test.add(detail)
+ let tree = XCTAttachment(string: app.debugDescription)
+ tree.name = "Audit finding accessibility tree"
+ tree.lifetime = .keepAlways
+ self.test.add(tree)
+ let screen = XCTAttachment(screenshot: app.screenshot())
+ screen.name = "Audit finding screen"
+ screen.lifetime = .keepAlways
+ self.test.add(screen)
+ if let element = issue.element {
+ let fresh = TeraAuditTarget(element, app: app).resolve(in: app)
+ let geometry = XCTAttachment(string: "Current target: \(fresh.debugDescription)")
+ geometry.lifetime = .keepAlways
+ self.test.add(geometry)
+ }
+ return self.retainFinding(issue)
+ }
+ }
+}
+
+@MainActor
+enum TeraAccessibilityNavigation {
+ static func viewport(_ app: XCUIApplication) -> (top: CGFloat, bottom: CGFloat) {
+ let navigation = app.navigationBars.firstMatch
+ let tabs = app.tabBars.firstMatch
+ // A presented account/import sheet covers the underlying tab bar. Its
+ // foreground content must use the sheet viewport, not the covered tabs.
+ let top = navigation.exists ? navigation.frame.maxY : 64
+ let bottom = tabs.exists && tabs.isHittable ? tabs.frame.minY : app.frame.maxY - 40
+ return (top, bottom)
+ }
+
+ static func scroll(_ app: XCUIApplication, to element: XCUIElement, up: Bool = true,
+ requiresHit: Bool = true, test: XCTestCase)
+ {
+ var forward = up
+ var previousViewport: [String] = []
+ var stationaryDrags = 0
+ for _ in 0 ..< 28 {
+ let position = scrollPosition(app)
+ let frame = element.exists ? String(describing: element.frame) : "not instantiated"
+ XCTContext.runActivity(named: "Accessibility scroll: position \(position ?? "unknown"), target \(frame)") { _ in }
+ if isVisible(element, app: app, requiresHit: requiresHit) {
+ return
+ }
+ // Lazy forms revise their estimated content height while revealing rows.
+ // A repeated percentage is not an edge. Reverse only after actual
+ // visible content has stopped moving across two completed drags.
+ let viewport = visibleContent(app)
+ stationaryDrags = !viewport.isEmpty && viewport == previousViewport ? stationaryDrags + 1 : 0
+ if stationaryDrags == 2 {
+ forward.toggle()
+ stationaryDrags = 0
+ }
+ previousViewport = viewport
+ if element.exists, element.frame.width > 0, element.frame.height > 0 {
+ let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
+ let frame = element.frame
+ let distance = bottom - top - 40
+ let delta = max(-distance, min(distance, frame.midY - (top + bottom) / 2))
+ if abs(delta) > 8 {
+ drag(app, amount: -delta, top: top, bottom: bottom)
+ continue
+ }
+ }
+ // Audit scans can leave a lazy form on either side of the target.
+ // Reverse only when scrolling has stopped making progress, so a long
+ // maximum-text form can use the whole bound to reach the opposite end.
+ let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
+ let delta = (bottom - top - 40) * (forward ? -0.7 : 0.7)
+ drag(app, amount: delta, top: top, bottom: bottom)
+ }
+ // The final drag can expose a lazy element. Check its settled frame before
+ // declaring the bounded search unsuccessful.
+ if isVisible(element, app: app, requiresHit: requiresHit) {
+ return
+ }
+ let screenshot = XCTAttachment(screenshot: app.screenshot())
+ screenshot.lifetime = .keepAlways
+ test.add(screenshot)
+ let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
+ let exists = element.exists
+ let frame = exists ? element.frame : .zero
+ let detail = exists ? element.debugDescription : "Target is not instantiated"
+ let geometry = String(format: "minY=%.17g maxY=%.17g top=%.17g bottom=%.17g", frame.minY, frame.maxY, top, bottom)
+ XCTFail("Required accessibility element is unreachable: exists=\(exists) hittable=\(exists && element.isHittable) \(geometry). \(detail).\n\(app.debugDescription)")
+ }
+
+ private static func isVisible(_ element: XCUIElement, app: XCUIApplication, requiresHit: Bool) -> Bool {
+ guard element.exists, !requiresHit || element.isHittable else { return false }
+ let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
+ let frame = element.frame
+ return contains(frame, top: top, bottom: bottom)
+ }
+
+ static func contains(_ frame: CGRect, top: CGFloat, bottom: CGFloat) -> Bool {
+ // UIKit's scaled geometry can differ by a few floating-point units at the
+ // same boundary (observed 189 versus 189.00000000000011). Eight ulps are
+ // far below a screen pixel; actual clipping still fails the native audits.
+ frame.width > 0 && frame.height > 0
+ && frame.minY >= top - top.ulp * 8
+ && frame.maxY <= bottom + bottom.ulp * 8
+ }
+
+ private static func scrollPosition(_ app: XCUIApplication) -> String? {
+ // Text editors have their own scrollbars. Progress belongs to the outer
+ // form, whose visible scrollbar is farther right than those nested editors.
+ let bars = app.otherElements.matching(NSPredicate(format: "label BEGINSWITH %@", "Vertical scroll bar"))
+ .allElementsBoundByIndex.filter { $0.frame.height > 0 && $0.frame.intersects(app.frame) }
+ let outer = bars.max { lhs, rhs in
+ lhs.frame.maxX == rhs.frame.maxX ? lhs.frame.height < rhs.frame.height : lhs.frame.maxX < rhs.frame.maxX
+ }
+ return outer?.value as? String
+ }
+
+ private static func visibleContent(_ app: XCUIApplication) -> [String] {
+ let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
+ return app.staticTexts.allElementsBoundByIndex.compactMap { text in
+ let frame = text.frame
+ guard frame.height > 0, frame.maxY > top, frame.minY < bottom else { return nil }
+ return "\(text.identifier):\(text.label):\(frame.minY.rounded()):\(frame.maxY.rounded())"
+ }
+ }
+
+ private static func drag(_ app: XCUIApplication, amount: CGFloat, top: CGFloat, bottom: CGFloat) {
+ // Application coordinates can resolve to a zero-width origin. The actual
+ // window gives a stable point in the form margin, outside nested editors.
+ let window = app.windows.firstMatch
+ let origin = window.coordinate(withNormalizedOffset: .zero)
+ // Start and end in the middle half of the scrollable area. The system
+ // bars can intercept gestures near the nominal safe-area boundaries.
+ let span = bottom - top
+ let delta = max(-span / 2, min(span / 2, amount))
+ let startY = (delta < 0 ? bottom - span / 4 : top + span / 4) - window.frame.minY
+ let start = origin.withOffset(CGVector(dx: 20, dy: startY))
+ // A second withOffset replaces the previous offset. Calculate both ends
+ // from the same origin so the destination stays inside the viewport.
+ let finish = origin.withOffset(CGVector(dx: 20, dy: startY + delta))
+ // Hold at the destination to stop momentum before reading the next frame.
+ start.press(forDuration: 0.05, thenDragTo: finish,
+ withVelocity: .slow, thenHoldForDuration: 0.3)
+ }
+}
+
+/// Contrast sampling under translucent navigation/tab bars can report a
+/// partially occluded element. Every such finding must pass a new contrast
+/// audit with its entire frame exposed. Unidentified findings remain fatal.
+private extension TeraAccessibilityAudit {
+ func auditContrast(_ app: XCUIApplication) throws {
+ var pending: [String: TeraAuditTarget] = [:]
+ var verified = Set<String>()
+ var requested: TeraAuditTarget?
+ for _ in 0 ..< 20 {
+ let target = requested?.resolve(in: app)
+ let targetKey = requested?.key
+ if let target {
+ // A menu's static label has no separate tap action. It still must be
+ // fully exposed and pass the actual native contrast re-audit below.
+ TeraAccessibilityNavigation.scroll(app, to: target, up: requested?.scrollUp ?? true,
+ requiresHit: requested?.type != .staticText, test: test)
+ }
+ let findingsBefore = findings.count
+ try app.performAccessibilityAudit(for: .contrast) { issue in
+ let observation = XCTAttachment(string: "Contrast observation: \(issue.detailedDescription)\n\(issue.element?.debugDescription ?? "unidentified")")
+ observation.lifetime = .keepAlways
+ self.test.add(observation)
+ guard let element = issue.element else { return self.retainFinding(issue) }
+ // SC 1.4.3 exempts inactive controls. Keep this bound to the actual
+ // disabled camera control; no active text or clipping finding is exempt.
+ if element.label == "Camera", !element.isEnabled,
+ app.buttons["Camera"].exists, !app.buttons["Camera"].isEnabled {
+ return true
+ }
+ let key = self.contrastKey(element)
+ guard key != targetKey, self.isPartlyOccluded(element, app: app) else { return self.retainFinding(issue) }
+ if !verified.contains(key) {
+ pending[key] = TeraAuditTarget(element, app: app)
+ }
+ let evidence = XCTAttachment(string: "Contrast requires unobscured recheck: \(element.debugDescription)")
+ evidence.lifetime = .keepAlways
+ self.test.add(evidence)
+ return true
+ }
+ if let target, let targetKey {
+ let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
+ XCTAssertTrue((requested?.type == .staticText || target.isHittable)
+ && TeraAccessibilityNavigation.contains(target.frame, top: top, bottom: bottom),
+ "Contrast recheck moved outside the viewport")
+ if findings.count == findingsBefore {
+ verified.insert(targetKey)
+ }
+ }
+ guard let next = pending.keys.sorted().first else { return }
+ requested = pending.removeValue(forKey: next)
+ }
+ XCTFail("Not all partially obscured contrast findings passed an unobscured recheck")
+ }
+
+ func retainFinding(_ issue: XCUIAccessibilityAuditIssue) -> Bool {
+ findings.append("\(issue.compactDescription): \(issue.element?.label ?? "unidentified")")
+ // Return every finding to the calling test for a mandatory assertion.
+ // Continuing this scan does not waive the finding.
+ return true
+ }
+
+ func contrastKey(_ element: XCUIElement) -> String {
+ "\(element.elementType.rawValue):\(element.identifier):\(element.label)"
+ }
+
+ func isPartlyOccluded(_ element: XCUIElement, app: XCUIApplication) -> Bool {
+ let frame = element.frame
+ let (top, bottom) = TeraAccessibilityNavigation.viewport(app)
+ guard frame.height > 0, frame.height < bottom - top - 16,
+ frame.minX >= app.frame.minX, frame.maxX <= app.frame.maxX else { return false }
+ return frame.minY < app.frame.maxY && frame.maxY > app.frame.minY
+ && !TeraAccessibilityNavigation.contains(frame, top: top, bottom: bottom)
+ }
+}
+
+private struct TeraAuditTarget {
+ let type: XCUIElement.ElementType
+ let identifier: String
+ let label: String
+ let scrollUp: Bool
+
+ var key: String {
+ "\(type.rawValue):\(identifier):\(label)"
+ }
+
+ @MainActor init(_ element: XCUIElement, app: XCUIApplication) {
+ type = element.elementType
+ identifier = element.identifier
+ label = element.label
+ scrollUp = element.frame.midY > app.frame.midY
+ }
+
+ @MainActor func resolve(in app: XCUIApplication) -> XCUIElement {
+ let predicate = identifier.isEmpty ? NSPredicate(format: "label == %@", label)
+ : NSPredicate(format: "identifier == %@ AND label == %@", identifier, label)
+ // UIKit-backed SwiftUI section headings expose different legacy and modern
+ // automation types. Match the stable identity and label across both trees.
+ return app.descendants(matching: .any).matching(predicate).firstMatch
+ }
+}
+
+@MainActor
+enum TeraAccessibilitySettings {
+ static func setSystemReduceMotion(_ enabled: Bool, restoring test: XCTestCase? = nil) -> Bool {
+ let settings = XCUIApplication(bundleIdentifier: "com.apple.Preferences")
+ // This helper operates Settings, not the app under qualification. Keep
+ // its navigation stable while Tera uses the real maximum system size.
+ settings.launchArguments = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
+ settings.launch()
+ if settings.navigationBars["Settings"].exists {
+ for _ in 0 ..< 3 {
+ settings.swipeDown()
+ }
+ }
+ for _ in 0 ..< 8 {
+ if settings.switches["Reduce Motion"].exists {
+ break
+ }
+ let motion = settings.buttons["Motion"].exists ? settings.buttons["Motion"] : settings.cells["Motion"]
+ let accessibility = settings.buttons["Accessibility"].exists ? settings.buttons["Accessibility"] : settings.cells["Accessibility"]
+ if motion.exists, motion.isHittable {
+ motion.tap()
+ } else if accessibility.exists, accessibility.isHittable {
+ accessibility.tap()
+ } else if settings.navigationBars.buttons.firstMatch.exists {
+ settings.navigationBars.buttons.firstMatch.tap()
+ } else {
+ settings.swipeUp()
+ }
+ }
+ let toggle = settings.switches["Reduce Motion"]
+ XCTAssertTrue(toggle.waitForExistence(timeout: 5), settings.debugDescription)
+ let original = toggle.value as? String == "1"
+ // Register restoration before changing the setting, including on failure.
+ if let test {
+ let originalSetting = XCTAttachment(string: "Original system Reduce Motion: \(original)")
+ originalSetting.lifetime = .keepAlways
+ test.add(originalSetting)
+ test.addTeardownBlock {
+ await MainActor.run {
+ _ = setSystemReduceMotion(original)
+ }
+ }
+ }
+ if original != enabled {
+ // Settings exposes the entire row as the switch accessibility element;
+ // its label does not toggle the visual control on this OS version.
+ toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap()
+ }
+ let settled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@", enabled ? "1" : "0"), object: toggle)
+ XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 5), .completed, settings.debugDescription)
+ settings.terminate()
+ return original
+ }
+}
diff --git a/TeraUITests/TeraAccessibilityUITests.swift b/TeraUITests/TeraAccessibilityUITests.swift
@@ -0,0 +1,242 @@
+import XCTest
+
+@MainActor
+final class TeraAccessibilityUITests: XCTestCase {
+ private var auditFindings: [String] = []
+
+ override func tearDown() async throws {
+ continueAfterFailure = true
+ XCTAssertTrue(auditFindings.isEmpty, "Unresolved accessibility findings:\n" + auditFindings.joined(separator: "\n"))
+ auditFindings.removeAll()
+ try await super.tearDown()
+ }
+
+ func testFormUpdateAtLargestTextWithReduceMotionAndLocalSave() throws {
+ try qualifyForm("Update")
+ }
+
+ func testFormPhotoUpdateAtLargestTextWithReduceMotionAndLocalSave() throws {
+ try qualifyForm("Photo update")
+ }
+
+ func testFormAskAtLargestTextWithReduceMotionAndLocalSave() throws {
+ try qualifyForm("Ask")
+ }
+
+ func testFormEventAtLargestTextWithReduceMotionAndLocalSave() throws {
+ try qualifyForm("Event")
+ }
+
+ func testFormFoodAvailabilityAtLargestTextWithReduceMotionAndLocalSave() throws {
+ try qualifyForm("Food availability")
+ }
+
+ func testUnconfirmedSaveStatusAtLargestText() throws {
+ let app = launch(scenario: "save-error", form: "Photo update")
+ XCTAssertTrue(app.tabBars.buttons["Add"].waitForExistence(timeout: 15))
+ app.tabBars.buttons["Add"].tap()
+ let prepare = app.buttons["Prepare unconfirmed Save"]
+ XCTAssertTrue(prepare.waitForExistence(timeout: 5))
+ prepare.tap()
+ XCTAssertTrue(prepare.waitForNonExistence(timeout: 5))
+ let error = app.staticTexts["The Add operation could not be completed."]
+ XCTAssertEqual(error.label, "The Add operation could not be completed.")
+ let state = app.descendants(matching: .any)["tera.add.save-state"]
+ XCTAssertTrue(state.waitForExistence(timeout: 5))
+ XCTAssertEqual(state.label, "Changes are not confirmed saved. Use Save draft to retry.")
+ scroll(app, to: state)
+ try audit(app)
+ }
+
+ func testActualPhotoDescriptionRemainsEditableAcrossSaveFailureAndRecovery() throws {
+ let app = launch(form: "Photo update")
+ XCTAssertTrue(app.tabBars.buttons["Add"].waitForExistence(timeout: 15))
+ app.tabBars.buttons["Add"].tap()
+ let library = app.buttons["Photo Library"]
+ scroll(app, to: library)
+ XCTAssertTrue(library.isHittable)
+ library.tap()
+ let description = app.descendants(matching: .any)["Describe this photo"]
+ scroll(app, to: description, up: false)
+ XCTAssertTrue(description.isHittable)
+ description.tap()
+ description.typeText("Carrots arranged in a wooden crate")
+ app.buttons["Done"].tap()
+ XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5))
+ XCTAssertEqual(description.value as? String, "Carrots arranged in a wooden crate")
+ try audit(app)
+ let save = app.buttons["Save draft"]
+ scroll(app, to: save)
+ save.tap()
+ let failed = app.descendants(matching: .any)["tera.add.save-state"]
+ XCTAssertTrue(failed.waitForExistence(timeout: 5))
+ XCTAssertTrue(failed.isHittable, "Explicit Save must reveal its acknowledgment")
+ scroll(app, to: failed, up: false)
+ XCTAssertEqual(failed.label, "Changes are not confirmed saved. Use Save draft to retry.")
+ try audit(app)
+ scroll(app, to: description)
+ XCTAssertEqual(description.value as? String, "Carrots arranged in a wooden crate")
+ let remove = app.buttons["Remove photo"]
+ scroll(app, to: remove)
+ remove.tap()
+ scroll(app, to: save)
+ save.tap()
+ let saved = app.descendants(matching: .any)["tera.add.save-state"]
+ XCTAssertTrue(saved.waitForExistence(timeout: 5))
+ XCTAssertTrue(saved.isHittable, "Explicit Save must reveal its acknowledgment")
+ scroll(app, to: saved, up: false)
+ XCTAssertEqual(saved.label, "Changes saved on this device.")
+ }
+
+ func testRuntimeFailureActionIsReachableAtLargestText() throws {
+ let app = launch(scenario: "failure")
+ let retry = app.buttons["Retry"]
+ XCTAssertTrue(retry.waitForExistence(timeout: 5))
+ try traverse(app, until: retry, auditing: true)
+ XCTAssertGreaterThanOrEqual(retry.frame.height, 44)
+ retry.tap()
+ XCTAssertTrue(app.staticTexts["Local state checked"].exists)
+ }
+
+ func testSupportSettingsAndDiagnosticsActionRemainReachableAtLargestText() throws {
+ let app = launch()
+ XCTAssertTrue(app.tabBars.buttons["Today"].waitForExistence(timeout: 15))
+ app.tabBars.buttons["Today"].tap()
+ app.buttons["Account"].tap()
+ let settings = app.buttons["Settings"]
+ scroll(app, to: settings)
+ settings.tap()
+ let diagnostics = app.buttons["Prepare diagnostics export"]
+ scroll(app, to: diagnostics)
+ XCTAssertGreaterThanOrEqual(diagnostics.frame.height, 44)
+ try audit(app)
+ let back = app.navigationBars.buttons["Me"]
+ XCTAssertTrue(back.waitForExistence(timeout: 5))
+ back.tap()
+ XCTAssertTrue(app.buttons["Done"].waitForExistence(timeout: 5))
+ app.buttons["Done"].tap()
+ XCTAssertTrue(app.tabBars.buttons["Today"].waitForExistence(timeout: 5))
+ }
+
+ func testSecureIdentityImportScalesAndCanBeDismissedWithoutSubmitting() throws {
+ let app = launch(scenario: "identity")
+ let open = app.buttons["Import identity"]
+ XCTAssertTrue(open.waitForExistence(timeout: 5))
+ try traverse(app, until: open, auditing: true)
+ open.tap()
+ for instruction in ["Enter an nsec or 64-character secret key.",
+ "It is transferred directly to Apple custody.",
+ "Secret input is never stored in view state."]
+ {
+ let text = app.staticTexts.matching(NSPredicate(format: "label == %@", instruction)).firstMatch
+ scroll(app, to: text)
+ try audit(app)
+ }
+ let field = app.secureTextFields["Secret identity key"]
+ scroll(app, to: field)
+ XCTAssertTrue(field.exists)
+ XCTAssertTrue(field.isHittable)
+ XCTAssertGreaterThanOrEqual(field.frame.height, 44)
+ try audit(app)
+ let submit = app.buttons["Import securely"]
+ scroll(app, to: submit)
+ XCTAssertTrue(submit.isHittable)
+ XCTAssertGreaterThanOrEqual(submit.frame.height, 44)
+ scroll(app, to: field, up: false)
+ field.tap()
+ field.typeText("invalid-key\n")
+ XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
+ XCTAssertTrue(app.staticTexts["Enter a valid Nostr secret key."].waitForExistence(timeout: 5))
+ app.buttons["Cancel"].tap()
+ XCTAssertTrue(open.waitForExistence(timeout: 5))
+ }
+}
+
+private extension TeraAccessibilityUITests {
+ func qualifyForm(_ type: String) throws {
+ let app = launch(form: type)
+ XCTAssertTrue(app.tabBars.buttons["Add"].waitForExistence(timeout: 15), app.debugDescription)
+ if type == "Update" {
+ app.tabBars.buttons["Today"].tap()
+ try audit(app, dynamicType: true)
+ }
+ app.tabBars.buttons["Add"].tap()
+ verifyEditingFocus(app, type: type)
+ try audit(app)
+ let save = app.buttons["Save draft"]
+ let disclosure = app.staticTexts["Submit saves an immutable local snapshot first."]
+ scroll(app, to: disclosure)
+ try audit(app)
+ // Audits may move a lazy form back to its earlier rows. Seek the action
+ // forward first; the bounded navigator reverses at an observed edge.
+ scroll(app, to: save)
+ XCTAssertTrue(save.isEnabled)
+ XCTAssertGreaterThanOrEqual(save.frame.height, 44)
+ save.tap()
+ let saved = app.descendants(matching: .any)["tera.add.save-state"]
+ XCTAssertTrue(saved.waitForExistence(timeout: 5))
+ XCTAssertTrue(saved.isHittable, "Explicit Save must reveal its acknowledgment")
+ scroll(app, to: saved, up: false)
+ XCTAssertEqual(saved.label, "Changes saved on this device.")
+ XCTAssertTrue(saved.isHittable)
+ try audit(app)
+ app.terminate()
+ }
+
+ func launch(scenario: String = "forms", form: String = "Update") -> XCUIApplication {
+ continueAfterFailure = false
+ _ = TeraAccessibilitySettings.setSystemReduceMotion(true, restoring: self)
+ let app = XCUIApplication()
+ app.launchEnvironment["TERA_IOS_UI_TEST_SHELL"] = "1"
+ app.launchEnvironment["TERA_IOS_UI_TEST_ACCESSIBILITY"] = scenario
+ app.launchEnvironment["TERA_IOS_UI_TEST_FORM"] = form
+ app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US",
+ "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
+ app.launch()
+ let root = app.otherElements["tera.test.accessibility.root"]
+ XCTAssertTrue(root.waitForExistence(timeout: 5))
+ let environment = XCTAttachment(string: "Observed presentation environment: \(root.value ?? "missing")")
+ environment.lifetime = .keepAlways
+ add(environment)
+ XCTAssertEqual(root.value as? String, "text=accessibility5; reduceMotion=true")
+ return app
+ }
+
+ func verifyEditingFocus(_ app: XCUIApplication, type: String) {
+ let labels = ["Update": "What’s happening locally?", "Photo update": "What should neighbors know?",
+ "Ask": "What do you need or want to know?", "Event": "Event details (optional)",
+ "Food availability": "Details"]
+ let content = app.textViews[labels[type]!]
+ scroll(app, to: content)
+ XCTAssertEqual(content.label, labels[type])
+ XCTAssertGreaterThanOrEqual(content.frame.height, 44)
+ content.tap()
+ XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
+ content.typeText("Seedlings available nearby.")
+ XCTAssertEqual(content.value as? String, "Seedlings available nearby.")
+ app.buttons["Done"].tap()
+ XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 5))
+ let traversal = XCTAttachment(string: app.debugDescription)
+ traversal.name = "\(type) editing accessibility tree"
+ traversal.lifetime = .keepAlways
+ add(traversal)
+ }
+
+ func audit(_ app: XCUIApplication, dynamicType: Bool = false) throws {
+ auditFindings += try TeraAccessibilityAudit(test: self).run(app, dynamicType: dynamicType)
+ }
+
+ func traverse(_ app: XCUIApplication, until element: XCUIElement, auditing: Bool) throws {
+ scroll(app, to: element)
+ if auditing {
+ try audit(app)
+ // Audits can move accessibility focus and scroll position. Restore the
+ // requested action before interacting with it.
+ scroll(app, to: element)
+ }
+ }
+
+ func scroll(_ app: XCUIApplication, to element: XCUIElement, up: Bool = true) {
+ TeraAccessibilityNavigation.scroll(app, to: element, up: up, test: self)
+ }
+}
diff --git a/TeraUITests/TeraEditingProtectionUITests.swift b/TeraUITests/TeraEditingProtectionUITests.swift
@@ -4,6 +4,7 @@ final class TeraEditingProtectionUITests: XCTestCase {
@MainActor
func testCancelDiscardConfirmationKeepsEditingAndExplicitDiscardAppliesOnce() {
let app = XCUIApplication()
+ app.launchArguments = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launchEnvironment["TERA_IOS_UI_TEST_SHELL"] = "1"
app.launchEnvironment["TERA_IOS_UI_TEST_EDITING_PROTECTION"] = "1"
app.launch()
diff --git a/TeraUITests/TeraPhotoQualificationInput.swift b/TeraUITests/TeraPhotoQualificationInput.swift
@@ -35,6 +35,7 @@ extension TeraRemoteQualificationUITests {
let preparedStatus = app.staticTexts.matching(
NSPredicate(format: "label == 'Photo prepared. Add descriptive text before publishing.'")
).firstMatch
+ TeraAccessibilityNavigation.scroll(app, to: preparedStatus, up: false, test: self)
guard preparedStatus.waitForExistence(timeout: 60) else {
XCTFail("The selected Photo update did not report the visible prepared state")
return nil
diff --git a/TeraUITests/TeraRecoveryUITests.swift b/TeraUITests/TeraRecoveryUITests.swift
@@ -4,6 +4,7 @@ final class TeraRecoveryUITests: XCTestCase {
@MainActor
func testUnknownLockedQuotaAndMismatchKeepEditingAndExposeSelectedCheck() {
let app = XCUIApplication()
+ app.launchArguments = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launchEnvironment["TERA_IOS_UI_TEST_SHELL"] = "1"
app.launchEnvironment["TERA_IOS_UI_TEST_RECOVERY"] = "1"
app.launch()
diff --git a/TeraUITests/TeraRemoteQualificationUITests.swift b/TeraUITests/TeraRemoteQualificationUITests.swift
@@ -135,12 +135,12 @@ final class TeraRemoteQualificationUITests: XCTestCase {
@MainActor
func testLocalSocialAccessibilitySemantics() throws {
let configuration = try QualificationConfiguration.environment()
+ _ = TeraAccessibilitySettings.setSystemReduceMotion(true, restoring: self)
let app = launchToRoot(
configuration,
launchArguments: [
"-AppleLanguages", "(en)",
"-AppleLocale", "en_US",
- "-UIAccessibilityReduceMotionEnabled", "YES",
]
)
@@ -442,85 +442,13 @@ final class TeraRemoteQualificationUITests: XCTestCase {
)
}
- private var accessibilityAuditTypes: XCUIAccessibilityAuditType {
- [
- .contrast,
- .elementDetection,
- .hitRegion,
- .sufficientElementDescription,
- .textClipped,
- .trait,
- ]
- }
-
- private var personaSemanticAuditTypes: XCUIAccessibilityAuditType {
- [
- .elementDetection,
- .hitRegion,
- .sufficientElementDescription,
- .textClipped,
- .trait,
- ]
- }
-
@MainActor
private func performLocalSocialAccessibilityAudit(
_ app: XCUIApplication,
includeContrast: Bool = true
) throws {
- let auditTypes = includeContrast ? accessibilityAuditTypes : personaSemanticAuditTypes
- try app.performAccessibilityAudit(for: auditTypes) { issue in
- self.recordAccessibilityIssue(issue)
- if issue.auditType == .contrast && issue.compactDescription == "Contrast nearly passed" {
- return true
- }
- if issue.auditType == .contrast,
- let element = issue.element,
- element.identifier.isEmpty,
- element.label.isEmpty
- {
- return true
- }
- if issue.auditType == .contrast,
- let element = issue.element,
- element.exists,
- !element.isEnabled
- {
- return true
- }
- if issue.auditType == .contrast,
- let element = issue.element,
- element.identifier == "radroots.add.submit"
- {
- return true
- }
- // Xcode 26 can emit text-clipping findings with no element, identifier,
- // label, type, or frame. Element-bound findings remain fatal.
- guard let element = issue.element else { return issue.auditType == .textClipped }
- guard element.exists else { return false }
- guard issue.auditType == .contrast || issue.auditType == .textClipped else { return false }
- return self.systemChromePartiallyOccludes(element, app: app)
- }
- }
-
- @MainActor
- private func systemChromePartiallyOccludes(_ element: XCUIElement, app: XCUIApplication) -> Bool {
- let frame = element.frame
- let navigationBar = app.navigationBars.firstMatch
- if navigationBar.exists {
- let boundary = navigationBar.frame.maxY
- if frame.minY < boundary, frame.maxY > boundary {
- return true
- }
- }
- let tabBar = app.tabBars.firstMatch
- if tabBar.exists {
- let boundary = tabBar.frame.minY
- if frame.minY < boundary, frame.maxY > boundary {
- return true
- }
- }
- return false
+ let findings = try TeraAccessibilityAudit(test: self).run(app, includeContrast: includeContrast)
+ XCTAssertTrue(findings.isEmpty, "Unresolved accessibility findings:\n" + findings.joined(separator: "\n"))
}
@MainActor
@@ -585,7 +513,7 @@ final class TeraRemoteQualificationUITests: XCTestCase {
case "radroots.add.summary":
"Short summary"
case "radroots.add.location":
- type == "Food availability" ? "Location" : "Location (optional)"
+ type == "Food availability" ? "Public location" : "Public location (optional)"
case "radroots.add.event_timing":
"When, Specific time"
case "radroots.add.event.start":
@@ -743,20 +671,6 @@ final class TeraRemoteQualificationUITests: XCTestCase {
}
@MainActor
- 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.tap()
- if type.waitForExistence(timeout: 10) {
- return type
- }
- }
- return nil
- }
-
- @MainActor
private func publishTextFlow(
_ app: XCUIApplication,
type: String,
@@ -1002,13 +916,14 @@ final class TeraRemoteQualificationUITests: XCTestCase {
throw QualificationError.missingProductSurface
}
let newDraft = app.buttons["radroots.add.new"]
- guard newDraft.waitForExistence(timeout: 10), waitUntilHittable(newDraft, timeout: 10) else {
+ guard beginNewDraft(newDraft) else {
XCTFail("The New draft action was unavailable")
throw QualificationError.missingProductSurface
}
- newDraft.tap()
+ // The explicit replacement must settle before changing the new form.
scrollAddFormToTop(app)
let picker = app.descendants(matching: .any)["radroots.add.type"]
+ scrollTo(app, element: picker)
guard picker.waitForExistence(timeout: 10), waitUntilHittable(picker, timeout: 10) else {
XCTFail("The Add type picker was unavailable")
throw QualificationError.missingProductSurface
@@ -1045,7 +960,7 @@ final class TeraRemoteQualificationUITests: XCTestCase {
XCTFail("The Add field \(identifier) did not accept keyboard focus")
throw QualificationError.missingProductSurface
}
- field.typeText(value)
+ enterExactText(field, value: value)
let done = app.buttons["radroots.add.keyboard.done"]
if done.waitForExistence(timeout: 5) {
done.tap()
@@ -1060,7 +975,7 @@ final class TeraRemoteQualificationUITests: XCTestCase {
else {
throw QualificationError.missingProductSurface
}
- guard !value.contains("Error code") else {
+ guard !value.contains("Error code"), finishOriginalPublication(app, submit: submit) else {
XCTFail("The local-social flow failed: \(value)")
throw QualificationError.productSubmissionFailed
}
@@ -1171,7 +1086,7 @@ final class TeraRemoteQualificationUITests: XCTestCase {
}
@MainActor
- private func submitAndWait(_ app: XCUIApplication, submit: XCUIElement) -> String? {
+ func submitAndWait(_ app: XCUIApplication, submit: XCUIElement) -> String? {
let status = app.staticTexts.matching(identifier: "radroots.add.status").firstMatch
let priorStatusLabel = status.exists ? status.label : nil
let priorSubmitValue = submit.value as? String
@@ -1192,7 +1107,7 @@ final class TeraRemoteQualificationUITests: XCTestCase {
return nil
}
scrollTo(app, element: submit)
- guard submit.waitForExistence(timeout: 10) else {
+ guard submit.waitForExistence(timeout: 10), terminalControlShowsSettledState(submit) else {
XCTFail("The terminal Add submission control was unavailable")
return nil
}
diff --git a/TeraUITests/TeraSubmissionQualificationSupport.swift b/TeraUITests/TeraSubmissionQualificationSupport.swift
@@ -2,6 +2,43 @@ import XCTest
extension TeraRemoteQualificationUITests {
@MainActor
+ func beginNewDraft(_ action: XCUIElement) -> Bool {
+ let ready = NSPredicate { _, _ in
+ action.exists && action.isEnabled && action.isHittable
+ }
+ guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: action)], timeout: 10) == .completed else { return false }
+ action.tap()
+ // New may first preserve the old editor through an asynchronous durable
+ // save. Editing before it settles intentionally cancels that replacement.
+ return XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: action)], timeout: 60) == .completed
+ }
+
+ @MainActor
+ func enterExactText(_ field: XCUIElement, value: String) {
+ field.typeText(value)
+ XCTAssertEqual(field.value as? String, value, "The fresh form must contain only the supplied field value")
+ }
+
+ @MainActor
+ func openAdd(_ app: XCUIApplication) -> XCUIElement? {
+ let add = app.tabBars.buttons["Add"]
+ guard add.waitForExistence(timeout: 10) else { return nil }
+ let root = app.descendants(matching: .any)["radroots.add.root"]
+ let type = app.descendants(matching: .any)["radroots.add.type"]
+ for _ in 0 ..< 3 {
+ add.tap()
+ guard root.waitForExistence(timeout: 10) else { continue }
+ // Public disclosure precedes the picker in the lazy native Form.
+ // Reveal its actual row before requiring existence and interaction.
+ scrollTo(app, element: type)
+ if type.waitForExistence(timeout: 10), type.isHittable {
+ return type
+ }
+ }
+ return nil
+ }
+
+ @MainActor
func recordAccessibilityIssue(_ issue: XCUIAccessibilityAuditIssue) {
let description = [
"Audit type: \(issue.auditType.rawValue)",
@@ -71,19 +108,71 @@ extension TeraRemoteQualificationUITests {
let startExpectation = XCTNSPredicateExpectation(predicate: started, object: app)
_ = XCTWaiter.wait(for: [startExpectation], timeout: 5)
let settled = NSPredicate { _, _ in
- addRoot.value as? String == "Ready" && !progress.exists && !submissionProgress.exists && submit.isEnabled
+ addRoot.value as? String == "Ready" && !progress.exists && !submissionProgress.exists
}
let settleExpectation = XCTNSPredicateExpectation(predicate: settled, object: app)
return XCTWaiter.wait(for: [settleExpectation], timeout: 180) == .completed
}
@MainActor
+ func terminalControlShowsSettledState(_ submit: XCUIElement) -> Bool {
+ if submit.isEnabled {
+ return true
+ }
+ // Both complete and temporarily deferred work disable a new effect.
+ // Successful qualification still requires actual policy completion below.
+ let recognized = ["Delivery policy complete", "Publication waiting"].contains(submit.label)
+ XCTAssertTrue(recognized, "Unexpected inactive publication control: \(submit.label)")
+ return recognized
+ }
+
+ @MainActor
+ func finishOriginalPublication(_ app: XCUIApplication, submit: XCUIElement) -> Bool {
+ for _ in 0 ..< 3 {
+ scrollTo(app, element: submit)
+ guard submit.waitForExistence(timeout: 10) else { return false }
+ if submit.label == "Delivery policy complete" {
+ return true
+ }
+ guard submit.label == "Publication waiting" else {
+ XCTFail("The original publication did not complete: \(submit.label)")
+ return false
+ }
+ let priorValue = submit.value as? String
+ let check = app.buttons["Check saved submission status"]
+ TeraAccessibilityNavigation.scroll(app, to: check, up: false, test: self)
+ guard check.waitForExistence(timeout: 10), check.isHittable else { return false }
+ check.tap()
+ guard waitForWorkToFinish(app, submit: submit, status: app.staticTexts["tera.add.submission.status"],
+ priorStatusLabel: nil, priorSubmitValue: priorValue) else { return false }
+ scrollTo(app, element: submit)
+ guard submit.waitForExistence(timeout: 10) else { return false }
+ if submit.isEnabled {
+ guard let ready = readySubmit(app), submitAndWait(app, submit: ready) != nil else { return false }
+ }
+ }
+ scrollTo(app, element: submit)
+ let completed = submit.exists && submit.label == "Delivery policy complete"
+ XCTAssertTrue(completed, "The original publication did not complete within three visible status/continuation attempts")
+ return completed
+ }
+
+ @MainActor
func assertSavedPhotoEditing(_ app: XCUIApplication) {
guard openDrafts(app) else { return XCTFail("The saved editing inventory did not open") }
- let savedPhoto = app.descendants(matching: .any).matching(
- NSPredicate(format: "identifier BEGINSWITH 'tera.add.composer.'")
+ let reopen = app.buttons.matching(
+ NSPredicate(format: "identifier BEGINSWITH 'tera.add.composer.' AND label == 'Reopen'")
).firstMatch
- XCTAssertTrue(savedPhoto.waitForExistence(timeout: 20))
- app.buttons["Done"].tap()
+ guard reopen.waitForExistence(timeout: 20) else {
+ return XCTFail("The persisted photo editing inventory omitted its Reopen action")
+ }
+ scrollTo(app, element: reopen)
+ XCTAssertTrue(waitUntilHittable(reopen, timeout: 10))
+ reopen.tap()
+ XCTAssertTrue(app.navigationBars["Drafts & outbox"].waitForNonExistence(timeout: 20))
+ let description = app.descendants(matching: .any)["Describe this photo"]
+ scrollTo(app, element: description)
+ XCTAssertEqual(description.value as? String, "A green test image prepared for the photo update",
+ "Reopening persisted photo editing must retain the supplied description")
}
}
diff --git a/project.yml b/project.yml
@@ -186,6 +186,8 @@ targets:
deploymentTarget: "18.0"
sources:
- path: TeraUITests/TeraRootShellUITests.swift
+ - path: TeraUITests/TeraAccessibilityUITests.swift
+ - path: TeraUITests/TeraAccessibilityAudit.swift
- path: TeraUITests/TeraEditingProtectionUITests.swift
- path: TeraUITests/TeraRecoveryUITests.swift
- path: TeraUITests/TeraPhotoQualificationInput.swift
diff --git a/release/provenance.json b/release/provenance.json
@@ -2,11 +2,11 @@
"artifacts": {
"app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b",
"ffi_api_sha256": "5d74060f97e52dad69ab7567c16d6b6b34acfab846f468450d89e2394a8a69d5",
- "ffi_provenance_sha256": "9b812a0aee55eec9eb733c52d445c17ee4796f68766b26b3bf7b4ea86f42959b",
+ "ffi_provenance_sha256": "48e9d6e38543760fb2ef45b93e3305258bee7079e4097f52ca5869891cbd2354",
"info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef",
"privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12",
"sbom_sha256": "904302bddb22ac1e4f9e79ab1145e590a8eeac7dd75e42539fb72eb0b7ebd4b1",
- "xcode_project_sha256": "187071ae7a525306c4fc32595ba1edde5c8eb823b5587da906cc05d6d4025a3c"
+ "xcode_project_sha256": "e95526f119f2e373f894ea32e63c353661bfceeae96d25d0bd5b797ca9caba09"
},
"disposition": "unsigned",
"platforms": [
@@ -22,7 +22,7 @@
"lib_revision": "8dbf27459be1729709a0ea36bba7470e90479ca3",
"source_date_epoch": 1787871027,
"swift_package_lock_sha256": "322eaec80d4b85ef9eb20da95ff95c4d26d8555ca358ba0c8eb890adf0b6671e",
- "tera_ffi_source_tree": "730344c702e337496a3979e05facd626d62535ab",
+ "tera_ffi_source_tree": "59106fa182618636b8a257584168019d5ed3e9f8",
"xcode_package_lock_sha256": "4e29fed46339d5e382fa78ec85b61be4296455ece4ddc40602d793c049e673b3"
},
"version": "0.1.0-alpha"
diff --git a/scripts/swift-quality.sh b/scripts/swift-quality.sh
@@ -38,6 +38,7 @@ readonly -a PYTHON_QUALITY_PATHS=(
scripts/test_package_contract.py
scripts/kotlin_smoke.py
scripts/test_kotlin_smoke.py
+ scripts/test_xcode_selection.py
)
command -v swiftformat >/dev/null || {
diff --git a/scripts/test_xcode_selection.py b/scripts/test_xcode_selection.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+class XcodeSelectionTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.directory = tempfile.TemporaryDirectory()
+ self.addCleanup(self.directory.cleanup)
+ self.root = Path(self.directory.name)
+ (self.root / "scripts").mkdir()
+ shutil.copyfile(ROOT / "scripts/xcode.sh", self.root / "scripts/xcode.sh")
+ (self.root / "TeraUITests").mkdir()
+ shutil.copyfile(
+ ROOT / "TeraUITests/TeraAccessibilityUITests.swift",
+ self.root / "TeraUITests/TeraAccessibilityUITests.swift",
+ )
+ self.arguments = self.root / "arguments.json"
+ executable = self.root / "xcodebuild"
+ executable.write_text(
+ f"#!{sys.executable}\n"
+ "import json, os, sys\n"
+ "from pathlib import Path\n"
+ "Path(os.environ['TEST_XCODE_ARGUMENTS']).write_text(json.dumps(sys.argv[1:]))\n"
+ )
+ executable.chmod(0o700)
+ self.environment = {
+ **os.environ,
+ "PATH": f"{self.root}:{os.environ['PATH']}",
+ "TEST_XCODE_ARGUMENTS": str(self.arguments),
+ "XCODE_DERIVED_DATA": str(self.root / "derived"),
+ "XCODE_SOURCE_PACKAGES": str(self.root / "sources"),
+ "XCODE_PACKAGE_CACHE": str(self.root / "cache"),
+ }
+
+ def run_selector(self, target: str, *selector: str) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [
+ "/bin/bash",
+ str(self.root / "scripts/xcode.sh"),
+ "project-test",
+ "platform=iOS Simulator,id=078A0172-207D-4EA6-8767-74DF7C42A73A",
+ target,
+ *selector,
+ ],
+ env=self.environment,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ def assert_selection(self, target: str, *selector: str) -> None:
+ result = self.run_selector(target, *selector)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ arguments = json.loads(self.arguments.read_text())
+ expected = target + (f"/{selector[0]}" if selector and selector[0] else "")
+ self.assertIn(f"-only-testing:{expected}", arguments)
+ self.assertEqual(arguments[-1], "test")
+ self.assertIn("-disableAutomaticPackageResolution", arguments)
+ self.assertIn("-onlyUsePackageVersionsFromResolvedFile", arguments)
+ for flag, variable in [
+ ("-derivedDataPath", "XCODE_DERIVED_DATA"),
+ ("-clonedSourcePackagesDirPath", "XCODE_SOURCE_PACKAGES"),
+ ("-packageCachePath", "XCODE_PACKAGE_CACHE"),
+ ]:
+ self.assertEqual(
+ arguments[arguments.index(flag) + 1], self.environment[variable]
+ )
+
+ def test_default_full_targets_and_empty_make_argument_are_preserved(self) -> None:
+ self.assert_selection("TeraTests")
+ self.assert_selection("TeraUITests")
+ self.assert_selection("TeraUITests", "")
+
+ def test_owned_class_and_method_select_exact_native_case(self) -> None:
+ self.assert_selection("TeraUITests", "TeraAccessibilityUITests")
+ self.assert_selection(
+ "TeraUITests",
+ "TeraAccessibilityUITests/testActualPhotoDescriptionRemainsEditableAcrossSaveFailureAndRecovery",
+ )
+
+ def test_unknown_or_injected_selector_never_launches_xcode(self) -> None:
+ for target, selector in [
+ ("TeraTests", "TeraAccessibilityUITests"),
+ ("Unknown", "TeraAccessibilityUITests"),
+ ("TeraUITests", "MissingUITests"),
+ ("TeraUITests", "TeraAccessibilityUITests/testMissing"),
+ ("TeraUITests", "../TeraAccessibilityUITests"),
+ ("TeraUITests", "TeraAccessibilityUITests/"),
+ (
+ "TeraUITests",
+ "TeraAccessibilityUITests/testMissing/-skip-testing:TeraUITests",
+ ),
+ ("TeraUITests", "TeraAccessibilityUITests;touch marker"),
+ ]:
+ with self.subTest(target=target, selector=selector):
+ result = self.run_selector(target, selector)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertFalse(self.arguments.exists())
+
+ def test_extra_arguments_never_launch_xcode(self) -> None:
+ result = self.run_selector("TeraUITests", "", "-skip-testing:TeraUITests")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertFalse(self.arguments.exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh
@@ -20,6 +20,7 @@ sh "$repo_root/scripts/ffi-provenance.sh" contract-check
scripts/test_ffi_artifacts.py \
scripts/test_ffi_installed.py \
scripts/test_kotlin_smoke.py \
+ scripts/test_xcode_selection.py \
scripts/test_local_social_fixture.py
)
diff --git a/scripts/xcode.sh b/scripts/xcode.sh
@@ -63,19 +63,44 @@ case "$operation" in
build
;;
project-test)
+ if [[ $# -lt 3 || $# -gt 4 ]]; then
+ echo "error: project-test requires destination, target and optional UI selector" >&2
+ exit 64
+ fi
destination=${2:?project-test requires a simulator destination}
test_target=${3:?project-test requires a test target}
case "$test_target" in
TeraTests|TeraUITests) ;;
*) echo "error: unsupported test target: $test_target" >&2; exit 1 ;;
esac
+ selector=${4:-}
+ selected_target=$test_target
+ if [[ -n "$selector" ]]; then
+ if [[ "$test_target" != TeraUITests || ! "$selector" =~ ^([A-Za-z][A-Za-z0-9]*UITests)(/(test[A-Za-z0-9]+))?$ ]]; then
+ echo "error: unsupported UI test selector" >&2
+ exit 64
+ fi
+ test_class=${BASH_REMATCH[1]}
+ test_method=${BASH_REMATCH[3]:-}
+ repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+ test_source="$repo_root/TeraUITests/$test_class.swift"
+ if [[ ! -f "$test_source" ]] || ! grep -Eq "^final class $test_class: XCTestCase" "$test_source"; then
+ echo "error: UI test class is not an owned test source" >&2
+ exit 64
+ fi
+ if [[ -n "$test_method" ]] && ! grep -Eq "^[[:space:]]+func $test_method\(\)" "$test_source"; then
+ echo "error: UI test method is not an owned test source" >&2
+ exit 64
+ fi
+ selected_target="$test_target/$selector"
+ fi
exec xcodebuild \
-project Tera.xcodeproj \
-scheme Tera \
-destination "$destination" \
"${output_args[@]}" \
"${offline_args[@]}" \
- "-only-testing:$test_target" \
+ "-only-testing:$selected_target" \
test
;;
physical-app-build)
diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json
@@ -525,6 +525,10 @@
"count": 2
},
{
+ "path": "Tera/Views/TeraAccessibilityUITestSurface.swift",
+ "count": 1
+ },
+ {
"path": "TeraTests/TeraBackgroundUploadFixtures.swift",
"count": 2
},
@@ -681,6 +685,10 @@
"count": 1
},
{
+ "path": "Tera/Views/TeraAccessibilityUITestSurface.swift",
+ "count": 1
+ },
+ {
"path": "TeraTests/TeraLocalBackupTests.swift",
"count": 1
},
@@ -1849,6 +1857,10 @@
"count": 1
},
{
+ "path": "Tera/Views/TeraAccessibilityUITestSurface.swift",
+ "count": 1
+ },
+ {
"path": "Tera/Views/TeraSupportingViews.swift",
"count": 1
},
@@ -3520,7 +3532,7 @@
},
{
"path": "TeraUITests/TeraSubmissionQualificationSupport.swift",
- "count": 1
+ "count": 2
}
]
},
@@ -3562,7 +3574,7 @@
},
{
"path": "TeraUITests/TeraRemoteQualificationUITests.swift",
- "count": 3
+ "count": 2
}
]
},
@@ -3604,7 +3616,11 @@
},
{
"path": "TeraUITests/TeraRemoteQualificationUITests.swift",
- "count": 2
+ "count": 1
+ },
+ {
+ "path": "TeraUITests/TeraSubmissionQualificationSupport.swift",
+ "count": 1
}
]
},
diff --git a/test-fixtures/maintainability-baseline.v1.json b/test-fixtures/maintainability-baseline.v1.json
@@ -15,7 +15,7 @@
{"path": "Tera/State/TeraConfigurationStore.swift", "maximum_lines": 759},
{"path": "TeraTests/TeraAddStoreTests.swift", "maximum_lines": 1366},
{"path": "TeraTests/TeraStateMigrationTests.swift", "maximum_lines": 718},
- {"path": "TeraUITests/TeraRemoteQualificationUITests.swift", "maximum_lines": 2140}
+ {"path": "TeraUITests/TeraRemoteQualificationUITests.swift", "maximum_lines": 1992}
],
"python_file_exception": [
{"path": "scripts/local-social-fixture.py", "maximum_lines": 2626},
diff --git a/test-fixtures/swiftlint-maintainability-baseline.v1.json b/test-fixtures/swiftlint-maintainability-baseline.v1.json
@@ -1 +1 @@
-[{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","location":{"line":21,"character":3,"file":"Tera/App/TeraAppModel.swift"},"severity":"error"},"text":" init("},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 373 lines","location":{"line":31,"character":7,"file":"Tera/Runtime/TeraGeneratedRuntimeBackend.swift"},"severity":"error"},"text":"final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1089","location":{"line":1089,"character":1,"file":"Tera/Runtime/TeraGeneratedRuntimeBackend.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 675 lines","location":{"line":3,"character":1,"file":"Tera/Runtime/TeraRuntimeClient.swift"},"severity":"error"},"text":"actor TeraRuntimeClient {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","location":{"line":353,"character":3,"file":"Tera/Runtime/TeraRuntimeClient.swift"},"severity":"error"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 749","location":{"line":749,"character":1,"file":"Tera/Runtime/TeraRuntimeClient.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1029","location":{"line":1029,"character":1,"file":"Tera/Runtime/TeraRuntimeModels.swift"},"severity":"error"},"text":"}"},{"violation":{"location":{"file":"Tera/State/TeraAddStore.swift","line":4,"character":7},"ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 481 lines","ruleIdentifier":"type_body_length","severity":"error"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 702","location":{"line":702,"character":1,"file":"Tera/State/TeraAddStore.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","location":{"line":78,"character":1,"file":"Tera/State/TeraConfigurationStore.swift"},"severity":"error"},"text":"actor TeraConfigurationStore {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","location":{"line":148,"character":5,"file":"Tera/State/TeraConfigurationStore.swift"},"severity":"error"},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 759","location":{"line":759,"character":1,"file":"Tera/State/TeraConfigurationStore.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 390 lines","location":{"line":39,"character":1,"file":"Tera/State/TeraSessionStore.swift"},"severity":"error"},"text":"actor TeraSessionStore {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleIdentifier":"cyclomatic_complexity","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 13","location":{"line":125,"character":11,"file":"Tera/State/TeraSessionStore.swift"},"severity":"error"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 452","location":{"line":452,"character":1,"file":"Tera/State/TeraSessionStore.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","location":{"line":365,"character":1,"file":"Tera/Views/TeraSupportingViews.swift"},"severity":"error"},"text":"struct TeraSettingsView: View {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 629","location":{"line":629,"character":1,"file":"Tera/Views/TeraSupportingViews.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 607 lines","location":{"line":6,"character":7,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 394 lines","location":{"line":682,"character":1,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":"actor AddBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Number of function parameters should be low.","ruleIdentifier":"function_parameter_count","ruleName":"Function Parameter Count","reason":"Function should have 5 parameters or less: it currently has 8","location":{"line":1020,"character":3,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":" func makeStatus("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1232","location":{"line":1232,"character":1,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","location":{"line":7,"character":7,"file":"TeraTests/TeraRemoteQualificationTests.swift"},"severity":"error"},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","location":{"line":45,"character":3,"file":"TeraTests/TeraRemoteQualificationTests.swift"},"severity":"error"},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","location":{"line":212,"character":3,"file":"TeraTests/TeraRemoteQualificationTests.swift"},"severity":"error"},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 298 lines","location":{"line":4,"character":7,"file":"TeraTests/TeraRuntimeClientTests.swift"},"severity":"error"},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","location":{"line":6,"character":7,"file":"TeraTests/TeraStateMigrationTests.swift"},"severity":"error"},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","location":{"line":7,"character":5,"file":"TeraTests/TeraStateMigrationTests.swift"},"severity":"error"},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 622","location":{"line":622,"character":1,"file":"TeraTests/TeraStateMigrationTests.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 252 lines","location":{"line":207,"character":9,"file":"TeraTests/TeraSupportingStoreTests.swift"},"severity":"error"},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 486","location":{"line":486,"character":1,"file":"TeraTests/TeraSupportingStoreTests.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1365 lines","location":{"line":4,"character":7,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 65 lines","location":{"line":62,"character":3,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","location":{"line":166,"character":3,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","location":{"line":239,"character":3,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleIdentifier":"cyclomatic_complexity","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 18","location":{"line":566,"character":11,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","location":{"line":1601,"character":10,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" static func environment("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 2077","location":{"line":2077,"character":1,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":"}"}]
+[{"violation":{"severity":"warning","ruleIdentifier":"function_body_length","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","location":{"character":3,"file":"Tera\/App\/TeraAppModel.swift","line":21},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" init("},{"violation":{"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 373 lines","location":{"character":7,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":31},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 1089","location":{"character":1,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":1089},"ruleDescription":"Files should not span too many lines.","ruleName":"File Length"},"text":"}"},{"violation":{"severity":"error","ruleIdentifier":"type_body_length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 675 lines","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":3},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"actor TeraRuntimeClient {"},{"violation":{"severity":"warning","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","location":{"character":3,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":353},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"severity":"warning","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 749","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":749},"ruleDescription":"Files should not span too many lines.","ruleName":"File Length"},"text":"}"},{"violation":{"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 1029","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeModels.swift","line":1029},"ruleDescription":"Files should not span too many lines.","ruleName":"File Length"},"text":"}"},{"violation":{"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 481 lines","location":{"character":7,"file":"Tera\/State\/TeraAddStore.swift","line":4},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"severity":"warning","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 702","location":{"character":1,"file":"Tera\/State\/TeraAddStore.swift","line":702},"ruleDescription":"Files should not span too many lines.","ruleName":"File Length"},"text":"}"},{"violation":{"severity":"error","ruleIdentifier":"type_body_length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":78},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"actor TeraConfigurationStore {"},{"violation":{"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","location":{"character":5,"file":"Tera\/State\/TeraConfigurationStore.swift","line":148},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"severity":"warning","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 759","location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":759},"ruleDescription":"Files should not span too many lines.","ruleName":"File Length"},"text":"}"},{"violation":{"severity":"warning","ruleIdentifier":"cyclomatic_complexity","reason":"Function should have complexity 10 or less; currently complexity is 13","location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":96},"ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 607 lines","location":{"character":7,"file":"TeraTests\/TeraAddStoreTests.swift","line":6},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"severity":"error","ruleIdentifier":"type_body_length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 394 lines","location":{"character":1,"file":"TeraTests\/TeraAddStoreTests.swift","line":682},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"actor AddBackend: TeraRuntimeBackend {"},{"violation":{"severity":"warning","ruleIdentifier":"function_parameter_count","reason":"Function should have 5 parameters or less: it currently has 8","location":{"character":3,"file":"TeraTests\/TeraAddStoreTests.swift","line":1020},"ruleDescription":"Number of function parameters should be low.","ruleName":"Function Parameter Count"},"text":" func makeStatus("},{"violation":{"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 1232","location":{"character":1,"file":"TeraTests\/TeraAddStoreTests.swift","line":1232},"ruleDescription":"Files should not span too many lines.","ruleName":"File Length"},"text":"}"},{"violation":{"severity":"warning","ruleIdentifier":"type_body_length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","location":{"character":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":7},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"severity":"warning","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":45},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"severity":"warning","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":212},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"severity":"warning","ruleIdentifier":"type_body_length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 298 lines","location":{"character":7,"file":"TeraTests\/TeraRuntimeClientTests.swift","line":4},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","location":{"character":7,"file":"TeraTests\/TeraStateMigrationTests.swift","line":6},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"severity":"warning","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","location":{"character":5,"file":"TeraTests\/TeraStateMigrationTests.swift","line":7},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"severity":"warning","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 622","location":{"character":1,"file":"TeraTests\/TeraStateMigrationTests.swift","line":622},"ruleDescription":"Files should not span too many lines.","ruleName":"File Length"},"text":"}"},{"violation":{"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1285 lines","location":{"character":7,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":4},"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length"},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"severity":"warning","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 65 lines","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":62},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"severity":"warning","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":166},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"severity":"warning","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":239},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"severity":"warning","ruleIdentifier":"cyclomatic_complexity","reason":"Function should have complexity 10 or less; currently complexity is 18","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":494},"ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity"},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"severity":"warning","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","location":{"character":10,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":1516},"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length"},"text":" static func environment("},{"violation":{"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 1992","location":{"character":1,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":1992},"ruleDescription":"Files should not span too many lines.","ruleName":"File Length"},"text":"}"}]
+\ No newline at end of file