commit 2b739836097054dda56a8aae85c4298750faea16
parent 3d5215d146a05fff52cb1f6c238de1741fe84b6f
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 18:05:39 -0700
field-ios: add background execution UI test probe
- expose a hidden deterministic background execution probe
- seed fake transfers and expired staged blobs for maintenance checks
- report redacted scheduling and cancellation probe state
- keep the probe behind field iOS UI-test launch configuration
Diffstat:
5 files changed, 214 insertions(+), 0 deletions(-)
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj
@@ -17,6 +17,7 @@
299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */; };
2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; };
2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; };
+ 2FAE0FC43EB547F2CE7A567D /* FieldBackgroundExecutionUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59AC0543EF8335D691D56BD3 /* FieldBackgroundExecutionUITestProbe.swift */; };
33A800AA701C354099623B24 /* MarketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C0B870E44C7B152A7FABE0 /* MarketView.swift */; };
35D8223F5E169DDB4E3E87C0 /* TradeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D224B525028DE5D8C8E28D /* TradeSettings.swift */; };
360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; };
@@ -78,6 +79,7 @@
491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldFileAccessUITestProbe.swift; sourceTree = "<group>"; };
4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.xcconfig; sourceTree = "<group>"; };
54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.git.xcconfig; sourceTree = "<group>"; };
+ 59AC0543EF8335D691D56BD3 /* FieldBackgroundExecutionUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldBackgroundExecutionUITestProbe.swift; sourceTree = "<group>"; };
63189EB90A86A9929BECD9ED /* Nostr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nostr.swift; sourceTree = "<group>"; };
676B89EB116B60AE8C2B4313 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
6FBB081610305940C7849C7C /* RelaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySettings.swift; sourceTree = "<group>"; };
@@ -195,6 +197,7 @@
children = (
A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */,
C614F2A8813E63C85261F492 /* FieldBackgroundExecution.swift */,
+ 59AC0543EF8335D691D56BD3 /* FieldBackgroundExecutionUITestProbe.swift */,
7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */,
E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */,
EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */,
@@ -432,6 +435,7 @@
9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */,
D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */,
ABBA5CC10933CA087E14A0E8 /* FieldBackgroundExecution.swift in Sources */,
+ 2FAE0FC43EB547F2CE7A567D /* FieldBackgroundExecutionUITestProbe.swift in Sources */,
299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */,
3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */,
E432FD39ECC8F03764EEED81 /* FieldDocumentInterchangeUITestProbe.swift in Sources */,
diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift
@@ -50,6 +50,13 @@ public struct AppEntry<Main: View>: View {
.accessibilityIdentifier("field_ios.telemetry.probe")
.accessibilityValue(probeValue)
}
+ if let probeValue = appState.backgroundExecutionProbeValue {
+ Color.clear
+ .frame(width: 1, height: 1)
+ .accessibilityElement()
+ .accessibilityIdentifier("field_ios.background_execution.probe")
+ .accessibilityValue(probeValue)
+ }
}
}
}
diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift
@@ -44,6 +44,7 @@ public final class AppState: ObservableObject {
@Published public private(set) var fileAccessProbeValue: String?
@Published public private(set) var documentInterchangeProbeValue: String?
@Published public private(set) var telemetryProbeValue: String?
+ @Published public private(set) var backgroundExecutionProbeValue: String?
@Published public private(set) var externalActionStatus: String?
@Published public private(set) var userPresenceStatus: String?
@Published public private(set) var canOpenNostrProfile: Bool = false
@@ -140,6 +141,7 @@ public final class AppState: ObservableObject {
let captureIntake = try FieldCaptureIntake.configured(bundleIdentifier: appBundleIdentifier)
self.captureIntake = captureIntake
try await backgroundExecution.start()
+ await refreshBackgroundExecutionProbe(using: backgroundExecution)
await refreshRuntimeState(using: service)
if runtimeIdentityReady && !isLocked {
startConnectingAndPollingStatus(using: service)
@@ -877,6 +879,10 @@ public final class AppState: ObservableObject {
telemetryProbeValue = await FieldTelemetryUITestProbe.value(recordedBy: telemetry)
}
+ private func refreshBackgroundExecutionProbe(using backgroundExecution: FieldBackgroundExecution) async {
+ backgroundExecutionProbeValue = await backgroundExecution.uiTestProbeValue()
+ }
+
private func shortNpub(_ value: String) -> String {
guard value.count > 18 else { return value }
return "\(value.prefix(12))...\(value.suffix(6))"
diff --git a/Radroots/Runtime/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift
@@ -189,6 +189,19 @@ actor FieldBackgroundExecution {
}
}
+ func uiTestProbeValue() async -> String? {
+ guard FieldBackgroundExecutionUITestProbe.isRequested else {
+ return nil
+ }
+ do {
+ return try await buildUITestProbeValue()
+ } catch {
+ return FieldBackgroundExecutionUITestProbe.failureValue(
+ outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)
+ )
+ }
+ }
+
@discardableResult
func performMaintenance(reason: String) async -> Bool {
let transferCount = await inspectTransferSnapshots(reason: reason)
@@ -206,6 +219,82 @@ actor FieldBackgroundExecution {
return succeeded
}
+ private func buildUITestProbeValue() async throws -> String {
+ let registered = hasRegisteredHandlers
+ let scheduledTaskCount = await fakeSubmittedRequestCount()
+ let pendingBeforeMaintenance = try await scheduler.pendingTasks().count
+ let transferSnapshotCount = try await seedUITestTransferSnapshot()
+ let stagedBlobRemoved = try await seedUITestStagedBlobAndRunMaintenance()
+ let pendingBeforeCancel = try await scheduler.pendingTasks().count
+ await cancelAll()
+ let pendingAfterCancel = try await scheduler.pendingTasks().count
+ let cancellationObserved = await fakeCancelAllCount() > 0
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ let events = await telemetry.recordedEventsForUITest()
+ return FieldBackgroundExecutionUITestProbe.value(
+ registered: registered,
+ scheduledTaskCount: scheduledTaskCount,
+ pendingBeforeMaintenance: pendingBeforeMaintenance,
+ pendingBeforeCancel: pendingBeforeCancel,
+ pendingAfterCancel: pendingAfterCancel,
+ cancellationObserved: cancellationObserved,
+ stagedBlobRemoved: stagedBlobRemoved,
+ transferSnapshotCount: transferSnapshotCount,
+ events: events
+ )
+ }
+
+ private func seedUITestTransferSnapshot() async throws -> Int {
+ guard let fakeTransfer = transfer as? RadrootsFakeBackgroundTransfer else {
+ return try await transfer.snapshots().count
+ }
+ let request = try RadrootsBackgroundTransferRequest(
+ remoteURL: URL(string: "https://radroots.org/field-ios-background-probe")!,
+ method: .get,
+ operation: .download(
+ destination: .file(
+ RadrootsFileReference(
+ scope: .cache,
+ relativePath: "ui_tests/background_execution/probe-download.bin"
+ )
+ )
+ ),
+ metadata: ["purpose": "background_execution_probe"]
+ )
+ _ = try await fakeTransfer.enqueue(request)
+ return try await fakeTransfer.snapshots().count
+ }
+
+ private func seedUITestStagedBlobAndRunMaintenance() async throws -> Bool {
+ let fileAccess = RadrootsAppleFileAccess(roots: roots)
+ let blob = try fileAccess.stageBlob(
+ Data("field-ios-background-probe".utf8),
+ mediaType: "text/plain",
+ filenameHint: "background-probe.txt"
+ )
+ let blobURL = try roots.stagedBlobURL(for: blob)
+ try FileManager.default.setAttributes(
+ [.modificationDate: Date(timeIntervalSince1970: 0)],
+ ofItemAtPath: blobURL.path
+ )
+ _ = await performMaintenance(reason: "ui_test_probe")
+ return !FileManager.default.fileExists(atPath: blobURL.path)
+ }
+
+ private func fakeSubmittedRequestCount() async -> Int {
+ guard let fakeScheduler = scheduler as? RadrootsFakeBackgroundTaskScheduler else {
+ return (try? await scheduler.pendingTasks().count) ?? 0
+ }
+ return await fakeScheduler.submittedRequestCount
+ }
+
+ private func fakeCancelAllCount() async -> Int {
+ guard let fakeScheduler = scheduler as? RadrootsFakeBackgroundTaskScheduler else {
+ return 0
+ }
+ return await fakeScheduler.cancelAllCount
+ }
+
private func inspectTransferSnapshots(reason: String) async -> Int? {
do {
let snapshots = try await transfer.snapshots()
diff --git a/Radroots/Runtime/FieldBackgroundExecutionUITestProbe.swift b/Radroots/Runtime/FieldBackgroundExecutionUITestProbe.swift
@@ -0,0 +1,108 @@
+import Foundation
+import RadrootsKit
+
+enum FieldBackgroundExecutionUITestProbe {
+ private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_BACKGROUND_EXECUTION_PROBE"
+ private static let redactionPolicy = RadrootsTelemetryRedactionPolicy.default
+
+ static var isRequested: Bool {
+ ProcessInfo.processInfo.environment[enabledKey] == "true"
+ }
+
+ static func value(
+ registered: Bool,
+ scheduledTaskCount: Int,
+ pendingBeforeMaintenance: Int,
+ pendingBeforeCancel: Int,
+ pendingAfterCancel: Int,
+ cancellationObserved: Bool,
+ stagedBlobRemoved: Bool,
+ transferSnapshotCount: Int,
+ events: [RadrootsTelemetryEvent]
+ ) -> String {
+ let eventNames = Set(events.map(\.name))
+ let values = events.flatMap(stringValues)
+ return [
+ "registered=\(registered)",
+ "scheduled_task_count=\(scheduledTaskCount)",
+ "schedule_observed=\(scheduledTaskCount >= 2)",
+ "pending_before_maintenance=\(pendingBeforeMaintenance)",
+ "pending_before_cancel=\(pendingBeforeCancel)",
+ "pending_after_cancel=\(pendingAfterCancel)",
+ "cancellation_observed=\(cancellationObserved)",
+ "staged_blob_removed=\(stagedBlobRemoved)",
+ "transfer_snapshot_count=\(transferSnapshotCount)",
+ "background_register_seen=\(eventNames.contains("field_ios.background_execution.register"))",
+ "background_schedule_seen=\(eventNames.contains("field_ios.background_execution.schedule"))",
+ "background_cancel_all_seen=\(eventNames.contains("field_ios.background_execution.cancel_all"))",
+ "background_transfer_inspect_seen=\(eventNames.contains("field_ios.background_execution.transfer_inspect"))",
+ "background_staged_blob_sweep_seen=\(eventNames.contains("field_ios.background_execution.staged_blob_sweep"))",
+ "background_relay_refresh_seen=\(eventNames.contains("field_ios.background_execution.relay_refresh"))",
+ "background_maintenance_seen=\(eventNames.contains("field_ios.background_execution.maintenance"))",
+ "unsafe_values_present=\(values.contains(where: containsUnsafeValue))",
+ "relay_url_values_present=\(values.contains(where: containsRelayURL))",
+ "secret_like_values_present=\(values.contains(where: containsSecretLikeValue))",
+ "path_like_values_present=\(values.contains(where: containsPathLikeValue))",
+ "npub_values_present=\(values.contains(where: containsNpubValue))"
+ ].joined(separator: ";")
+ }
+
+ static func failureValue(outcome: String) -> String {
+ [
+ "registered=false",
+ "scheduled_task_count=0",
+ "pending_before_maintenance=0",
+ "pending_before_cancel=0",
+ "pending_after_cancel=0",
+ "cancellation_observed=false",
+ "staged_blob_removed=false",
+ "transfer_snapshot_count=0",
+ "probe_failure_outcome=\(outcome)",
+ "unsafe_values_present=false",
+ "relay_url_values_present=false",
+ "secret_like_values_present=false",
+ "path_like_values_present=false",
+ "npub_values_present=false"
+ ].joined(separator: ";")
+ }
+
+ private static func stringValues(from event: RadrootsTelemetryEvent) -> [String] {
+ var values = event.message.map { [$0] } ?? []
+ values.append(contentsOf: event.fields.map { field in
+ field.value.renderedValue
+ })
+ return values
+ }
+
+ private static func containsUnsafeValue(_ value: String) -> Bool {
+ redactionPolicy.containsUnsafeValue(value)
+ || containsRelayURL(value)
+ || containsSecretLikeValue(value)
+ || containsPathLikeValue(value)
+ || containsNpubValue(value)
+ }
+
+ private static func containsRelayURL(_ value: String) -> Bool {
+ let normalized = value.lowercased()
+ return normalized.contains("ws://") || normalized.contains("wss://")
+ }
+
+ private static func containsSecretLikeValue(_ value: String) -> Bool {
+ let normalized = value.lowercased()
+ return normalized.contains("nsec")
+ || normalized.range(of: "[a-f0-9]{64}", options: .regularExpression) != nil
+ }
+
+ private static func containsPathLikeValue(_ value: String) -> Bool {
+ let normalized = value.lowercased()
+ return normalized.contains("/users/")
+ || normalized.contains("/private/var/")
+ || normalized.contains("/var/mobile/containers/")
+ || normalized.contains("/var/folders/")
+ || normalized.contains("file:///")
+ }
+
+ private static func containsNpubValue(_ value: String) -> Bool {
+ value.lowercased().contains("npub")
+ }
+}