commit 6bf34408018038fc1147ae51fd8982910035556c
parent 9df573e320843630930caa90e478b6cf502cc5e7
Author: triesap <tyson@radroots.org>
Date: Fri, 28 Aug 2026 08:00:09 +0000
fix(ios): harden local social accessibility
Diffstat:
8 files changed, 533 insertions(+), 92 deletions(-)
diff --git a/README.md b/README.md
@@ -72,6 +72,27 @@ This harness is Debug-only, binds only explicit loopback ports, records bounded
protocol evidence under the extbuild results root, and cannot become a
production endpoint fallback.
+Passing `accessibility` as the final launcher argument runs Apple's
+accessibility audit over every progressively disclosed Add composition at the
+largest accessibility text size with Reduce Motion enabled. The corresponding
+fixture verifier requires zero publication and upload effects:
+
+```sh
+RADROOTS_IOS_UI_TEST_RUN_ID=local-social-accessibility \
+cargo extbuild run -- scripts/xcode.sh local-social-ui-test \
+ 'platform=iOS Simulator,id=SIMULATOR-UDID' \
+ local-social-accessibility \
+ accessibility
+```
+
+Element-bound clipping findings remain fatal. The harness tolerates only
+Xcode's elementless clipping diagnostics and narrowly identified contrast
+false positives for disabled controls, system-chrome overlap, and the
+black-on-white Submit button.
+
+This automated gate does not substitute for the parent-owned five-participant
+formative study. No human-usability claim belongs in this standalone capsule.
+
## Package surface
`Package.swift` publishes the `RadrootsApp` library used by the generated Xcode
diff --git a/Radroots/App/RadrootsRootShell.swift b/Radroots/App/RadrootsRootShell.swift
@@ -85,6 +85,9 @@ struct RadrootsRootShell: View {
.tag(RadrootsRootTab.add)
.accessibilityIdentifier("radroots.tab.add")
}
+ .tint(.primary)
+ .toolbarBackground(Color(uiColor: .systemBackground), for: .tabBar)
+ .toolbarBackground(.visible, for: .tabBar)
.accessibilityIdentifier("radroots.root.tabs")
.onOpenURL { url in
guard let tab = RadrootsRootTab.resolve(url: url) else { return }
diff --git a/Radroots/Views/RadrootsAddView.swift b/Radroots/Views/RadrootsAddView.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import UIKit
struct RadrootsAddView: View {
private enum FocusedField: Hashable {
@@ -13,6 +14,7 @@ struct RadrootsAddView: View {
}
@ObservedObject var store: RadrootsAddStore
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var showsDrafts = false
@FocusState private var focusedField: FocusedField?
@@ -71,6 +73,8 @@ struct RadrootsAddView: View {
Section {
Button("Save draft") { Task { await store.save() } }
+ .buttonStyle(.borderedProminent)
+ .tint(.primary)
.disabled(!store.canSave)
.accessibilityIdentifier("radroots.add.save")
@@ -81,24 +85,28 @@ struct RadrootsAddView: View {
.disabled(store.isWorking)
.accessibilityIdentifier("radroots.add.cancel")
}
- } footer: {
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)
+
+ if dynamicTypeSize.isAccessibilitySize {
+ submitButton
+ }
}
}
+ .headerProminence(.increased)
+ .tint(.primary)
.accessibilityIdentifier("radroots.add.root")
.safeAreaInset(edge: .bottom, spacing: 0) {
- Button(submitLabel) { Task { await store.submit() } }
- .accessibilityIdentifier("radroots.add.submit")
- .accessibilityValue(submitAccessibilityValue)
- .buttonStyle(.borderedProminent)
- .frame(maxWidth: .infinity)
- .padding(.horizontal)
- .padding(.vertical, 8)
- .padding(.bottom, 72)
- .background(.bar)
- .disabled(!store.canSubmit)
+ if !dynamicTypeSize.isAccessibilitySize {
+ submitButton
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ .padding(.bottom, 72)
+ .background(.bar)
+ }
}
.disabled(store.isWorking)
.overlay {
@@ -110,6 +118,8 @@ struct RadrootsAddView: View {
}
}
.navigationTitle("Add")
+ .toolbarBackground(Color(uiColor: .systemBackground), for: .navigationBar)
+ .toolbarBackground(.visible, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Drafts") { showsDrafts = true }
@@ -153,13 +163,15 @@ struct RadrootsAddView: View {
private var eventFields: some View {
Section("Event") {
- TextField("Title", text: optional(\.title))
- .focused($focusedField, equals: .title)
- .accessibilityIdentifier("radroots.add.title")
+ formTextField(
+ label: "Title", text: optional(\.title), focus: .title,
+ identifier: "radroots.add.title"
+ )
contentEditor(prompt: "Event details (optional)")
- TextField("Location (optional)", text: optional(\.location))
- .focused($focusedField, equals: .location)
- .accessibilityIdentifier("radroots.add.location")
+ formTextField(
+ label: "Location (optional)", text: optional(\.location), focus: .location,
+ identifier: "radroots.add.location"
+ )
Picker("When", selection: optionalTiming) {
ForEach(RadrootsEventTiming.allCases) { timing in
Text(timing.label).tag(Optional(timing))
@@ -168,10 +180,18 @@ struct RadrootsAddView: View {
.accessibilityIdentifier("radroots.add.event_timing")
if store.form.eventTiming == .allDay {
DatePicker("Starts", selection: allDayStart, displayedComponents: .date)
+ .accessibilityLabel("Starts")
+ .accessibilityIdentifier("radroots.add.event.start")
DatePicker("Ends", selection: allDayEnd, displayedComponents: .date)
+ .accessibilityLabel("Ends")
+ .accessibilityIdentifier("radroots.add.event.end")
} else {
DatePicker("Starts", selection: timedStart)
+ .accessibilityLabel("Starts")
+ .accessibilityIdentifier("radroots.add.event.start")
DatePicker("Ends", selection: timedEnd)
+ .accessibilityLabel("Ends")
+ .accessibilityIdentifier("radroots.add.event.end")
}
}
}
@@ -179,26 +199,29 @@ struct RadrootsAddView: View {
@ViewBuilder
private var foodFields: some View {
Section("Food availability") {
- TextField("Food", text: optional(\.title))
- .focused($focusedField, equals: .title)
- .accessibilityIdentifier("radroots.add.title")
- TextField("Short summary", text: optional(\.summary))
- .focused($focusedField, equals: .summary)
- .accessibilityIdentifier("radroots.add.summary")
+ formTextField(
+ label: "Food", text: optional(\.title), focus: .title,
+ identifier: "radroots.add.title"
+ )
+ formTextField(
+ label: "Short summary", text: optional(\.summary), focus: .summary,
+ identifier: "radroots.add.summary"
+ )
contentEditor(prompt: "Details")
- TextField("Location", text: optional(\.location))
- .focused($focusedField, equals: .location)
- .accessibilityIdentifier("radroots.add.location")
+ formTextField(
+ label: "Location", text: optional(\.location), focus: .location,
+ identifier: "radroots.add.location"
+ )
}
Section("Price and quantity") {
- TextField("Price", text: optional(\.priceAmount))
- .keyboardType(.decimalPad)
- .focused($focusedField, equals: .price)
- .accessibilityIdentifier("radroots.add.price")
- TextField("Currency", text: optional(\.currency))
- .textInputAutocapitalization(.characters)
- .focused($focusedField, equals: .currency)
- .accessibilityIdentifier("radroots.add.currency")
+ formTextField(
+ label: "Price", text: optional(\.priceAmount), focus: .price,
+ identifier: "radroots.add.price", keyboardType: .decimalPad
+ )
+ formTextField(
+ label: "Currency", text: optional(\.currency), focus: .currency,
+ identifier: "radroots.add.currency", capitalization: .characters
+ )
Picker("Unit", selection: optional(\.unit)) {
Text("Choose a unit").tag("")
ForEach(unitChoices, id: \.self) { unit in
@@ -206,9 +229,10 @@ struct RadrootsAddView: View {
}
}
.accessibilityIdentifier("radroots.add.unit")
- TextField("Quantity available (optional)", text: optional(\.quantity))
- .keyboardType(.decimalPad)
- .focused($focusedField, equals: .quantity)
+ formTextField(
+ label: "Quantity available (optional)", text: optional(\.quantity), focus: .quantity,
+ identifier: "radroots.add.quantity", keyboardType: .decimalPad
+ )
}
}
@@ -219,7 +243,7 @@ struct RadrootsAddView: View {
Label("Prepared image", systemImage: "checkmark.shield")
.font(.subheadline.weight(.semibold))
Text(
- "\(media.width) × \(media.height) · \(ByteCountFormatter.string(fromByteCount: Int64(media.byteSize), countStyle: .file))"
+ "\(media.width) × \(media.height) · \(ByteCountFormatter.string(fromByteCount: Int64(clamping: media.byteSize), countStyle: .file))"
)
.font(.caption)
.foregroundStyle(.secondary)
@@ -231,54 +255,116 @@ struct RadrootsAddView: View {
)
)
.focused($focusedField, equals: .media(media.id))
- .accessibilityIdentifier("radroots.add.media.alt.\(media.id)")
+ .accessibilityIdentifier("radroots.add.media.alt")
Button("Remove photo", role: .destructive) { store.removeMedia(id: media.id) }
}
.accessibilityElement(children: .contain)
- .accessibilityIdentifier("radroots.add.media.\(media.sha256)")
+ .accessibilityIdentifier("radroots.add.media.prepared")
.accessibilityValue(
- "sha256 \(media.sha256), \(media.byteSize) bytes, \(media.width) by \(media.height)"
+ "\(media.width) by \(media.height), "
+ + ByteCountFormatter.string(
+ fromByteCount: Int64(clamping: media.byteSize), countStyle: .file)
)
}
- HStack {
- Button {
- Task { await store.importPhotos() }
- } label: {
- Label("Photo Library", systemImage: "photo.on.rectangle")
+ ViewThatFits(in: .horizontal) {
+ HStack {
+ mediaLibraryButton
+ Spacer()
+ mediaCameraButton
}
- .disabled(!store.mediaSupport.library || !store.canAddMedia)
- .accessibilityIdentifier("radroots.add.media.library")
+ VStack(alignment: .leading, spacing: 12) {
+ mediaLibraryButton
+ mediaCameraButton
+ }
+ }
+ }
+ }
- Spacer()
+ private var mediaLibraryButton: some View {
+ Button {
+ Task { await store.importPhotos() }
+ } label: {
+ HStack(alignment: .firstTextBaseline) {
+ Image(systemName: "photo.on.rectangle")
+ Text("Library")
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .disabled(!store.mediaSupport.library || !store.canAddMedia)
+ .accessibilityLabel("Photo Library")
+ .accessibilityIdentifier("radroots.add.media.library")
+ }
- Button {
- Task { await store.capturePhoto() }
- } label: {
- Label("Camera", systemImage: "camera")
- }
- .disabled(!store.mediaSupport.camera || !store.canAddMedia)
- .accessibilityIdentifier("radroots.add.media.camera")
+ private var mediaCameraButton: some View {
+ Button {
+ Task { await store.capturePhoto() }
+ } label: {
+ HStack(alignment: .firstTextBaseline) {
+ Image(systemName: "camera")
+ Text("Camera")
+ .fixedSize(horizontal: false, vertical: true)
}
}
+ .disabled(!store.mediaSupport.camera || !store.canAddMedia)
+ .accessibilityIdentifier("radroots.add.media.camera")
}
- private func contentEditor(prompt: String) -> some View {
+ private func contentEditor(prompt: LocalizedStringKey) -> some View {
ZStack(alignment: .topLeading) {
TextEditor(text: required(\.content))
.frame(minHeight: 110)
.focused($focusedField, equals: .content)
+ .accessibilityLabel(Text(prompt))
.accessibilityIdentifier("radroots.add.content")
if store.form.content.isEmpty {
Text(prompt)
- .foregroundStyle(.tertiary)
+ .foregroundStyle(.primary)
.padding(.horizontal, 5)
.padding(.vertical, 8)
.allowsHitTesting(false)
+ .accessibilityHidden(true)
}
}
}
+ private var submitButton: some View {
+ Button {
+ Task { await store.submit() }
+ } label: {
+ Text(submitLabel)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity)
+ }
+ .accessibilityIdentifier("radroots.add.submit")
+ .accessibilityValue(submitAccessibilityValue)
+ .buttonStyle(.borderedProminent)
+ .tint(.primary)
+ .disabled(!store.canSubmit)
+ }
+
+ private func formTextField(
+ label: LocalizedStringKey,
+ text: Binding<String>,
+ focus: FocusedField,
+ identifier: String,
+ keyboardType: UIKeyboardType = .default,
+ capitalization: TextInputAutocapitalization? = nil
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(label)
+ .font(.subheadline.weight(.semibold))
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityHidden(true)
+ TextField("", text: text)
+ .keyboardType(keyboardType)
+ .textInputAutocapitalization(capitalization)
+ .focused($focusedField, equals: focus)
+ .accessibilityLabel(Text(label))
+ .accessibilityIdentifier(identifier)
+ }
+ }
+
private var availableTypes: [RadrootsAddCommandType] {
let types = store.schemas.map(\.commandType)
return types.isEmpty ? RadrootsAddCommandType.allCases : types
diff --git a/Radroots/Views/RadrootsTodayView.swift b/Radroots/Views/RadrootsTodayView.swift
@@ -23,14 +23,7 @@ struct RadrootsTodayView: View {
ProgressView("Loading Today…")
.accessibilityIdentifier("radroots.today.loading")
case .empty:
- ContentUnavailableView {
- Label("Nothing here yet", systemImage: "leaf")
- } description: {
- Text("Pull to refresh or add the first update to this local network.")
- } actions: {
- Button("Refresh") { Task { await store.reload() } }
- .accessibilityIdentifier("radroots.today.refresh")
- }
+ emptyView
case .failed(let message) where store.cards.isEmpty:
unavailableView(
title: "Today is unavailable",
@@ -80,6 +73,30 @@ struct RadrootsTodayView: View {
.task { await store.start() }
}
+ private var emptyView: some View {
+ ScrollView {
+ VStack(spacing: 16) {
+ Image(systemName: "leaf")
+ .font(.largeTitle)
+ .foregroundStyle(.secondary)
+ .accessibilityHidden(true)
+ Text("Nothing here yet")
+ .font(.title2.weight(.semibold))
+ .multilineTextAlignment(.center)
+ Text("Pull to refresh or add the first update to this local network.")
+ .multilineTextAlignment(.center)
+ Button("Refresh") { Task { await store.reload() } }
+ .buttonStyle(.borderedProminent)
+ .tint(.primary)
+ .frame(minWidth: 44, minHeight: 44)
+ .contentShape(Rectangle())
+ .accessibilityIdentifier("radroots.today.refresh")
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+ }
+
private var feed: some View {
List {
if case .offline(let message) = store.state {
@@ -303,19 +320,29 @@ struct RadrootsTodayCardView: View {
}
private var foodMetadata: some View {
- HStack(spacing: 12) {
- if let price = card.priceSummary {
- Label(price, systemImage: "tag")
- }
- if let quantity = card.quantity, let unit = card.priceUnit {
- Label("\(quantity) \(unit) available", systemImage: "basket")
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 12) {
+ foodMetadataLabels
}
- if let location = card.location {
- Label(location, systemImage: "mappin.and.ellipse")
+ VStack(alignment: .leading, spacing: 4) {
+ foodMetadataLabels
}
}
.font(.subheadline)
}
+
+ @ViewBuilder
+ private var foodMetadataLabels: some View {
+ if let price = card.priceSummary {
+ Label(price, systemImage: "tag")
+ }
+ if let quantity = card.quantity, let unit = card.priceUnit {
+ Label("\(quantity) \(unit) available", systemImage: "basket")
+ }
+ if let location = card.location {
+ Label(location, systemImage: "mappin.and.ellipse")
+ }
+ }
}
struct RadrootsTrustedMediaView: View {
diff --git a/RadrootsUITests/RadrootsRemoteQualificationUITests.swift b/RadrootsUITests/RadrootsRemoteQualificationUITests.swift
@@ -122,6 +122,36 @@ final class RadrootsRemoteQualificationUITests: XCTestCase {
}
@MainActor
+ func testLocalSocialAccessibilitySemantics() throws {
+ let configuration = try QualificationConfiguration.environment()
+ let app = launchToRoot(
+ configuration,
+ launchArguments: [
+ "-AppleLanguages", "(en)",
+ "-AppleLocale", "en_US",
+ "-UIAccessibilityReduceMotionEnabled", "YES",
+ ]
+ )
+
+ XCTAssertEqual(app.tabBars.firstMatch.buttons.count, 2)
+ XCTAssertGreaterThan(app.staticTexts["Nothing here yet"].frame.height, 36)
+ XCTAssertGreaterThan(
+ app.staticTexts["Pull to refresh or add the first update to this local network."].frame
+ .height,
+ 44
+ )
+ app.tabBars.buttons["Today"].tap()
+ try performLocalSocialAccessibilityAudit(app)
+
+ for type in ["Update", "Photo update", "Ask", "Event", "Food availability"] {
+ try beginDraft(app, type: type)
+ assertProgressiveDisclosure(app, type: type)
+ scrollTo(app, element: app.buttons["radroots.add.submit"])
+ try performLocalSocialAccessibilityAudit(app)
+ }
+ }
+
+ @MainActor
func testRemoteBlossomUploadAndRecovery() throws {
let configuration = try QualificationConfiguration.environment()
let app = launchToRoot(configuration)
@@ -304,14 +334,196 @@ final class RadrootsRemoteQualificationUITests: XCTestCase {
}
@MainActor
- private func launchToRoot(_ configuration: QualificationConfiguration) -> XCUIApplication {
+ private func launchToRoot(
+ _ configuration: QualificationConfiguration,
+ launchArguments: [String] = []
+ ) -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment = configuration.launchEnvironment
+ app.launchArguments = launchArguments
app.launch()
reachRoot(app)
return app
}
+ private var accessibilityAuditTypes: XCUIAccessibilityAuditType {
+ [
+ .contrast,
+ .elementDetection,
+ .hitRegion,
+ .sufficientElementDescription,
+ .textClipped,
+ .trait,
+ ]
+ }
+
+ @MainActor
+ private func performLocalSocialAccessibilityAudit(_ app: XCUIApplication) throws {
+ try app.performAccessibilityAudit(for: accessibilityAuditTypes) { issue in
+ if issue.auditType == .contrast && issue.compactDescription == "Contrast nearly passed" {
+ 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
+ }
+
+ @MainActor
+ private func assertProgressiveDisclosure(_ app: XCUIApplication, type: String) {
+ scrollAddFormToTop(app)
+ let expected = expectedAddFields[type] ?? []
+ for identifier in allAddFields {
+ let element = app.descendants(matching: .any)[identifier]
+ if expected.contains(identifier) {
+ scrollTo(app, element: element)
+ XCTAssertTrue(
+ element.waitForExistence(timeout: 10),
+ "Missing progressive-disclosure field for \(type): \(identifier)"
+ )
+ XCTAssertEqual(
+ element.label,
+ expectedAddFieldLabel(identifier, type: type),
+ "Unexpected accessibility label: \(identifier)"
+ )
+ } else {
+ XCTAssertFalse(
+ element.exists,
+ "Unexpected progressive-disclosure field for \(type): \(identifier)"
+ )
+ }
+ }
+ let expectsMedia = type != "Update"
+ let mediaLibrary = app.buttons["radroots.add.media.library"]
+ let mediaCamera = app.buttons["radroots.add.media.camera"]
+ if expectsMedia {
+ scrollTo(app, element: mediaLibrary)
+ XCTAssertTrue(mediaLibrary.waitForExistence(timeout: 10))
+ XCTAssertTrue(mediaCamera.waitForExistence(timeout: 10))
+ XCTAssertEqual(mediaLibrary.label, "Photo Library")
+ XCTAssertEqual(mediaCamera.label, "Camera")
+ } else {
+ XCTAssertFalse(mediaLibrary.exists, "Unexpected media-library disclosure for \(type)")
+ XCTAssertFalse(mediaCamera.exists, "Unexpected camera disclosure for \(type)")
+ }
+ }
+
+ private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {
+ switch identifier {
+ case "radroots.add.content":
+ switch type {
+ case "Update":
+ return "What’s happening locally?"
+ case "Photo update":
+ return "What should neighbors know?"
+ case "Ask":
+ return "What do you need or want to know?"
+ case "Event":
+ return "Event details (optional)"
+ case "Food availability":
+ return "Details"
+ default:
+ preconditionFailure("Unrecognized Add type: \(type)")
+ }
+ case "radroots.add.title":
+ return type == "Food availability" ? "Food" : "Title"
+ case "radroots.add.summary":
+ return "Short summary"
+ case "radroots.add.location":
+ return type == "Food availability" ? "Location" : "Location (optional)"
+ case "radroots.add.event_timing":
+ return "When, Specific time"
+ case "radroots.add.event.start":
+ return "Starts"
+ case "radroots.add.event.end":
+ return "Ends"
+ case "radroots.add.price":
+ return "Price"
+ case "radroots.add.currency":
+ return "Currency"
+ case "radroots.add.unit":
+ return "Unit, Choose a unit"
+ case "radroots.add.quantity":
+ return "Quantity available (optional)"
+ default:
+ preconditionFailure("Unrecognized Add field identifier: \(identifier)")
+ }
+ }
+
+ private var allAddFields: [String] {
+ [
+ "radroots.add.content",
+ "radroots.add.title",
+ "radroots.add.summary",
+ "radroots.add.location",
+ "radroots.add.event_timing",
+ "radroots.add.event.start",
+ "radroots.add.event.end",
+ "radroots.add.price",
+ "radroots.add.currency",
+ "radroots.add.unit",
+ "radroots.add.quantity",
+ ]
+ }
+
+ private var expectedAddFields: [String: Set<String>] {
+ [
+ "Update": ["radroots.add.content"],
+ "Photo update": ["radroots.add.content"],
+ "Ask": ["radroots.add.content"],
+ "Event": [
+ "radroots.add.content",
+ "radroots.add.title",
+ "radroots.add.location",
+ "radroots.add.event_timing",
+ "radroots.add.event.start",
+ "radroots.add.event.end",
+ ],
+ "Food availability": [
+ "radroots.add.content",
+ "radroots.add.title",
+ "radroots.add.summary",
+ "radroots.add.location",
+ "radroots.add.price",
+ "radroots.add.currency",
+ "radroots.add.unit",
+ "radroots.add.quantity",
+ ],
+ ]
+ }
+
@MainActor
private func reachRoot(_ app: XCUIApplication) {
for _ in 0..<6 {
@@ -463,12 +675,7 @@ final class RadrootsRemoteQualificationUITests: XCTestCase {
XCTAssertTrue(library.waitForExistence(timeout: 10))
XCTAssertTrue(library.isEnabled)
library.tap()
- let prepared = app.descendants(matching: .any).matching(
- NSPredicate(
- format: "identifier MATCHES %@",
- #"radroots\.add\.media\.[0-9a-f]{64}"#
- )
- ).firstMatch
+ let prepared = app.descendants(matching: .any)["radroots.add.media.prepared"]
XCTAssertTrue(prepared.waitForExistence(timeout: 30))
return readySubmit(app)
}
@@ -578,6 +785,12 @@ final class RadrootsRemoteQualificationUITests: XCTestCase {
}
@MainActor
+ private func scrollAddFormToTop(_ app: XCUIApplication) {
+ let root = app.descendants(matching: .any)["radroots.add.root"]
+ for _ in 0..<8 { root.swipeDown() }
+ }
+
+ @MainActor
private func assertTodayContains(_ app: XCUIApplication, markers: [String]) {
app.tabBars.buttons["Today"].tap()
let refresh = app.buttons["radroots.today.refresh"]
diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py
@@ -54,7 +54,9 @@ class FixtureState:
self._subscriptions += 1
events = list(self._events.values())
self._write_evidence_locked()
- selected = [event for event in events if any(matches(event, item) for item in filters)]
+ selected = [
+ event for event in events if any(matches(event, item) for item in filters)
+ ]
selected.sort(key=lambda item: (item.get("created_at", 0), item.get("id", "")))
limits = [
item["limit"]
@@ -64,7 +66,9 @@ class FixtureState:
count = min(max(max(limits, default=len(selected)), 0), MAX_EVENTS)
return selected[-count:] if count else []
- def upload(self, body: bytes, media_type: str, expected_hash: str) -> tuple[bool, dict[str, Any]]:
+ def upload(
+ self, body: bytes, media_type: str, expected_hash: str
+ ) -> tuple[bool, dict[str, Any]]:
digest = hashlib.sha256(body).hexdigest()
with self._lock:
self._upload_attempts += 1
@@ -111,7 +115,9 @@ class FixtureState:
"retrievals": self._retrievals,
}
temporary = self._evidence.with_suffix(".tmp")
- temporary.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
+ temporary.write_text(
+ json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8"
+ )
os.replace(temporary, self._evidence)
@@ -120,7 +126,10 @@ def matches(event: dict[str, Any], item: dict[str, Any]) -> bool:
return False
if isinstance(item.get("ids"), list) and event.get("id") not in item["ids"]:
return False
- if isinstance(item.get("authors"), list) and event.get("pubkey") not in item["authors"]:
+ if (
+ isinstance(item.get("authors"), list)
+ and event.get("pubkey") not in item["authors"]
+ ):
return False
if isinstance(item.get("kinds"), list) and event.get("kind") not in item["kinds"]:
return False
@@ -213,7 +222,9 @@ class RelayHandler(socketserver.BaseRequestHandler):
if not key or fields.get("upgrade", "").lower() != "websocket":
return
accept = base64.b64encode(
- hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
+ hashlib.sha1(
+ (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()
+ ).digest()
).decode()
self.request.sendall(
(
@@ -241,10 +252,17 @@ class RelayHandler(socketserver.BaseRequestHandler):
continue
if not isinstance(message, list) or not message:
continue
- if message[0] == "EVENT" and len(message) == 2 and isinstance(message[1], dict):
+ if (
+ message[0] == "EVENT"
+ and len(message) == 2
+ and isinstance(message[1], dict)
+ ):
event_id = message[1].get("id", "")
accepted = self.state.publish(message[1])
- send_json(self.request, ["OK", event_id, accepted, "" if accepted else "invalid"])
+ send_json(
+ self.request,
+ ["OK", event_id, accepted, "" if accepted else "invalid"],
+ )
elif (
message[0] == "REQ"
and len(message) >= 3
@@ -351,7 +369,11 @@ class BlossomHandler(http.server.BaseHTTPRequestHandler):
if not accepted:
self.respond(503, b'{"error":"fixture_retry_required"}', "application/json")
return
- self.respond(200, json.dumps(descriptor, separators=(",", ":")).encode(), "application/json")
+ self.respond(
+ 200,
+ json.dumps(descriptor, separators=(",", ":")).encode(),
+ "application/json",
+ )
def do_GET(self) -> None: # noqa: N802
component = self.path.removeprefix("/")
@@ -439,6 +461,21 @@ def verify(arguments: argparse.Namespace) -> int:
return 0
+def verify_accessibility(arguments: argparse.Namespace) -> int:
+ payload = json.loads(Path(arguments.evidence).read_text(encoding="utf-8"))
+ if (
+ payload.get("schema") != "radroots-ios-local-social-fixture-evidence-v1"
+ or payload.get("accepted_events") != 0
+ or payload.get("upload_attempts") != 0
+ or payload.get("accepted_uploads") != 0
+ or payload.get("retrievals") != 0
+ or payload.get("subscriptions", 0) < 1
+ ):
+ raise SystemExit("local-social accessibility fixture evidence is invalid")
+ print("local-social accessibility fixture evidence verified")
+ return 0
+
+
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser()
commands = root.add_subparsers(dest="command", required=True)
@@ -450,6 +487,8 @@ def parser() -> argparse.ArgumentParser:
serve_command.add_argument("--control", required=True)
verify_command = commands.add_parser("verify")
verify_command.add_argument("--evidence", required=True)
+ accessibility_command = commands.add_parser("verify-accessibility")
+ accessibility_command.add_argument("--evidence", required=True)
return root
@@ -457,7 +496,9 @@ def main() -> int:
arguments = parser().parse_args()
if arguments.command == "serve":
return serve(arguments)
- return verify(arguments)
+ if arguments.command == "verify":
+ return verify(arguments)
+ return verify_accessibility(arguments)
if __name__ == "__main__":
diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh
@@ -83,6 +83,20 @@ grep -Fq '<key>RADROOTS_IOS_UI_TEST_NETWORK_PROFILE</key>' \
grep -Fq 'local-social-ui-test)' "$repo_root/scripts/xcode.sh"
grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialFiveFlowScenario' \
"$repo_root/scripts/xcode.sh"
+grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialAccessibilitySemantics' \
+ "$repo_root/scripts/xcode.sh"
+grep -Fq 'verify-accessibility' "$repo_root/scripts/local-social-fixture.py"
+grep -Fq 'performAccessibilityAudit' \
+ "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift"
+grep -Fq 'element.identifier == "radroots.add.submit"' \
+ "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift"
+grep -Fq 'return issue.auditType == .textClipped' \
+ "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift"
+grep -Fq 'issue.auditType == .contrast || issue.auditType == .textClipped' \
+ "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift"
+grep -Fq -- '-UIAccessibilityReduceMotionEnabled' \
+ "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift"
+grep -Fq 'accessibility-extra-extra-extra-large' "$repo_root/scripts/xcode.sh"
grep -Fq '#if DEBUG' "$repo_root/Radroots/App/RadrootsRemoteQualification.swift"
grep -Fq 'case "simulator": runtimeMode = "simulator"' \
"$repo_root/Radroots/App/RadrootsRemoteQualification.swift"
diff --git a/scripts/xcode.sh b/scripts/xcode.sh
@@ -135,6 +135,7 @@ case "$operation" in
local-social-ui-test)
destination=${2:?local-social-ui-test requires a simulator destination}
result_name=${3:?local-social-ui-test requires a result name}
+ scenario=${4:-five-flow}
qualification_run_id=${RADROOTS_IOS_UI_TEST_RUN_ID:?RADROOTS_IOS_UI_TEST_RUN_ID is required}
relay_port=${RADROOTS_IOS_LOCAL_SOCIAL_RELAY_PORT:-21000}
blossom_port=${RADROOTS_IOS_LOCAL_SOCIAL_BLOSSOM_PORT:-21100}
@@ -160,6 +161,24 @@ case "$operation" in
echo "error: local-social-ui-test ports must be distinct" >&2
exit 64
fi
+ case "$scenario" in
+ five-flow)
+ test_selector=RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialFiveFlowScenario
+ evidence_command=verify
+ simulator_id=
+ previous_content_size=
+ ;;
+ accessibility)
+ test_selector=RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialAccessibilitySemantics
+ evidence_command=verify-accessibility
+ simulator_id=${destination##*id=}
+ previous_content_size=
+ ;;
+ *)
+ echo "error: unsupported local-social-ui-test scenario: $scenario" >&2
+ exit 64
+ ;;
+ esac
: "${XCODE_RESULTS:?XCODE_RESULTS is required}"
mkdir -p "$XCODE_RESULTS"
result_bundle="$XCODE_RESULTS/$result_name.xcresult"
@@ -180,6 +199,10 @@ case "$operation" in
cleanup_fixture() {
kill "$fixture_pid" 2>/dev/null || true
wait "$fixture_pid" 2>/dev/null || true
+ if [[ -n "$previous_content_size" ]]; then
+ xcrun simctl ui "$simulator_id" content_size "$previous_content_size"
+ previous_content_size=
+ fi
}
trap cleanup_fixture EXIT INT TERM
for _ in {1..100}; do
@@ -194,6 +217,19 @@ case "$operation" in
echo "error: local-social fixture readiness timed out" >&2
exit 1
fi
+ if [[ "$scenario" == accessibility ]]; then
+ xcrun simctl boot "$simulator_id" >/dev/null 2>&1 || true
+ xcrun simctl bootstatus "$simulator_id" -b >/dev/null
+ previous_content_size=$(xcrun simctl ui "$simulator_id" content_size)
+ case "$previous_content_size" in
+ extra-small|small|medium|large|extra-large|extra-extra-large|extra-extra-extra-large|accessibility-medium|accessibility-large|accessibility-extra-large|accessibility-extra-extra-large|accessibility-extra-extra-extra-large) ;;
+ *)
+ echo "error: simulator content-size state is unavailable" >&2
+ exit 1
+ ;;
+ esac
+ xcrun simctl ui "$simulator_id" content_size accessibility-extra-extra-extra-large
+ fi
set +e
xcodebuild \
-project Radroots.xcodeproj \
@@ -203,7 +239,7 @@ case "$operation" in
"${output_args[@]}" \
"${offline_args[@]}" \
-resultBundlePath "$result_bundle" \
- "-only-testing:RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialFiveFlowScenario" \
+ "-only-testing:$test_selector" \
"RADROOTS_IOS_UI_TEST_RUN_ID=$qualification_run_id" \
"RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS=ws://127.0.0.1:$relay_port" \
"RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=http://127.0.0.1:$blossom_port" \
@@ -217,7 +253,7 @@ case "$operation" in
if ((test_status != 0)); then
exit "$test_status"
fi
- python3 scripts/local-social-fixture.py verify --evidence "$evidence"
+ python3 scripts/local-social-fixture.py "$evidence_command" --evidence "$evidence"
;;
remote-ui-test)
destination=${2:?remote-ui-test requires a simulator destination}