field_ios

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

commit 6bf34408018038fc1147ae51fd8982910035556c
parent 9df573e320843630930caa90e478b6cf502cc5e7
Author: triesap <tyson@radroots.org>
Date:   Fri, 28 Aug 2026 08:00:09 +0000

fix(ios): harden local social accessibility

Diffstat:
MREADME.md | 21+++++++++++++++++++++
MRadroots/App/RadrootsRootShell.swift | 3+++
MRadroots/Views/RadrootsAddView.swift | 202++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
MRadroots/Views/RadrootsTodayView.swift | 59+++++++++++++++++++++++++++++++++++++++++++----------------
MRadrootsUITests/RadrootsRemoteQualificationUITests.swift | 227++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mscripts/local-social-fixture.py | 59++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mscripts/verify-package-contract.sh | 14++++++++++++++
Mscripts/xcode.sh | 40++++++++++++++++++++++++++++++++++++++--
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}