test(ios-prefetch): enhance testing infrastructure and validation

Apply comprehensive enhancements to iOS prefetch plugin testing and
validation system per directive requirements.

Technical Correctness Improvements:
- Enhanced BGTask scheduling with validation (60s minimum lead time)
- Implemented one active task rule (cancel existing before scheduling)
- Added graceful simulator error handling (Code=1 expected)
- Follow Apple best practice: schedule next task immediately at execution
- Ensure task completion even on expiration with guard flag
- Improved error handling and structured logging

Testing Coverage Expansion:
- Added edge case scenarios table (7 scenarios: Background Refresh Off,
  Low Power Mode, Force-Quit, Timezone Change, DST, Multi-Day, Reboot)
- Expanded failure injection tests (8 new negative-path scenarios)
- Documented automated testing strategies (unit and integration tests)

Validation Enhancements:
- Added structured JSON logging schema for events
- Provided log validation script (validate-ios-logs.sh)
- Enhanced test run template with telemetry and state verification
- Documented state integrity checks (content hash, schedule hash)
- Added UI indicators and persistent test artifacts requirements

Documentation Updates:
- Enhanced IOS_PREFETCH_TESTING.md with comprehensive test strategies
- Added Technical Correctness Requirements to IOS_TEST_APP_REQUIREMENTS.md
- Expanded error handling test cases from 2 to 7 scenarios
- Created ENHANCEMENTS_APPLIED.md summary document

Files modified:
- ios/Plugin/DailyNotificationBackgroundTaskTestHarness.swift: Enhanced
  with technical correctness improvements
- doc/test-app-ios/IOS_PREFETCH_TESTING.md: Expanded testing coverage
- doc/test-app-ios/IOS_TEST_APP_REQUIREMENTS.md: Added technical
  requirements
- doc/test-app-ios/ENHANCEMENTS_APPLIED.md: New summary document
This commit is contained in:
Matthew Raymer
2025-11-17 06:37:06 +00:00
parent f6875beae5
commit 95507c6121
4 changed files with 783 additions and 20 deletions

View File

@@ -80,23 +80,82 @@ class DailyNotificationBackgroundTaskTestHarness {
/// Schedule a BGAppRefreshTask for prefetch
///
/// **Validation:**
/// - Ensures earliestBeginDate is at least 60 seconds in future (iOS requirement)
/// - Cancels any existing pending task for this notification (one active task rule)
/// - Handles simulator limitations gracefully (Code=1 error is expected)
///
/// - Parameter earliestOffsetSeconds: Seconds from now when task can begin
/// - Parameter notificationId: Optional notification ID to cancel existing task
/// - Returns: true if scheduling succeeded, false otherwise
@discardableResult
static func schedulePrefetchTask(earliestOffsetSeconds: TimeInterval) -> Bool {
static func schedulePrefetchTask(earliestOffsetSeconds: TimeInterval, notificationId: String? = nil) -> Bool {
// Validate minimum lead time (iOS requires at least 60 seconds)
guard earliestOffsetSeconds >= 60 else {
print("[DNP-FETCH] ERROR: earliestOffsetSeconds must be >= 60 (iOS requirement)")
return false
}
// One Active Task Rule: Cancel any existing pending task for this notification
if let notificationId = notificationId {
cancelPendingTask(for: notificationId)
}
let request = BGAppRefreshTaskRequest(identifier: prefetchTaskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: earliestOffsetSeconds)
do {
try BGTaskScheduler.shared.submit(request)
print("[DNP-FETCH] BGAppRefreshTask scheduled (earliestBeginDate=\(String(describing: request.earliestBeginDate)))")
fetchLogger.info("[DNP-FETCH] BGAppRefreshTask scheduled (earliestBeginDate=\(String(describing: request.earliestBeginDate)))")
return true
} catch {
// Handle simulator limitation (Code=1 is expected on simulator)
if let nsError = error as NSError?, nsError.domain == "BGTaskSchedulerErrorDomain", nsError.code == 1 {
fetchLogger.warning("[DNP-FETCH] BGTask scheduling failed on simulator (expected): Code=1 notPermitted")
print("[DNP-FETCH] NOTE: BGTaskScheduler doesn't work on simulator - this is expected. Use Xcode → Debug → Simulate Background Fetch for testing.")
return false
}
fetchLogger.error("[DNP-FETCH] Failed to schedule BGAppRefreshTask: \(error.localizedDescription)")
print("[DNP-FETCH] Failed to schedule BGAppRefreshTask: \(error)")
return false
}
}
/// Cancel pending task for a specific notification (one active task rule)
private static func cancelPendingTask(for notificationId: String) {
// Get all pending tasks
BGTaskScheduler.shared.getPendingTaskRequests { requests in
for request in requests {
if request.identifier == prefetchTaskIdentifier {
// In real implementation, check if this request matches the notificationId
// For now, cancel all prefetch tasks (Phase 1: single notification)
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: prefetchTaskIdentifier)
fetchLogger.info("[DNP-FETCH] Cancelled existing pending task for notificationId=\(notificationId)")
}
}
}
}
/// Verify only one active task is pending (debug helper)
static func verifyOneActiveTask() -> Bool {
var pendingCount = 0
let semaphore = DispatchSemaphore(value: 0)
BGTaskScheduler.shared.getPendingTaskRequests { requests in
pendingCount = requests.filter { $0.identifier == prefetchTaskIdentifier }.count
semaphore.signal()
}
semaphore.wait()
if pendingCount > 1 {
fetchLogger.warning("[DNP-FETCH] WARNING: Multiple pending tasks detected (\(pendingCount)) - expected 1")
return false
}
return true
}
// MARK: - Time Warp Simulation (Testing)
/// Simulate time warp for accelerated testing
@@ -135,12 +194,19 @@ class DailyNotificationBackgroundTaskTestHarness {
/// Handle BGAppRefreshTask execution
///
/// **Apple Best Practice Pattern:**
/// 1. Schedule next task immediately (at start of execution)
/// 2. Initiate async work
/// 3. Mark task as complete
/// 4. Use expiration handler to cancel if needed
///
/// This is called by the system when the background task is launched.
/// Replace PrefetchOperation with your actual prefetch logic.
private static func handlePrefetchTask(task: BGAppRefreshTask) {
print("[DNP-FETCH] BGTask handler invoked (task.identifier=\(task.identifier))")
fetchLogger.info("[DNP-FETCH] BGTask handler invoked (task.identifier=\(task.identifier))")
// Schedule the next one early, so that there's always a pending task
// STEP 1: Schedule the next task IMMEDIATELY (Apple best practice)
// This ensures continuity even if app is terminated shortly after
// In real implementation, calculate next schedule based on notification time
schedulePrefetchTask(earliestOffsetSeconds: 60 * 30) // 30 minutes later, for example
@@ -149,19 +215,34 @@ class DailyNotificationBackgroundTaskTestHarness {
queue.maxConcurrentOperationCount = 1
let operation = PrefetchOperation()
var taskCompleted = false
// Set expiration handler
// Called if iOS decides to end the task early (typically ~30 seconds)
// STEP 4: Set expiration handler (called if iOS terminates task early, ~30 seconds)
task.expirationHandler = {
print("[DNP-FETCH] Task expired")
fetchLogger.warning("[DNP-FETCH] Task expired - cancelling operations")
operation.cancel()
// Ensure task is marked complete even on expiration
if !taskCompleted {
taskCompleted = true
task.setTaskCompleted(success: false)
fetchLogger.info("[DNP-FETCH] Task marked complete (success=false) due to expiration")
}
}
// Set completion handler
// STEP 2: Initiate async work
// STEP 3: Mark task as complete when done
operation.completionBlock = {
let success = !operation.isCancelled
print("[DNP-FETCH] Task completionBlock (success=\(success))")
task.setTaskCompleted(success: success)
let success = !operation.isCancelled && !operation.isFailed
// Ensure setTaskCompleted is called exactly once
if !taskCompleted {
taskCompleted = true
task.setTaskCompleted(success: success)
fetchLogger.info("[DNP-FETCH] Task marked complete (success=\(success))")
} else {
fetchLogger.warning("[DNP-FETCH] WARNING: Attempted to complete task twice")
}
}
queue.addOperation(operation)
@@ -180,22 +261,38 @@ class DailyNotificationBackgroundTaskTestHarness {
/// - Error handling
class PrefetchOperation: Operation {
var isFailed = false
private let fetchLogger = Logger(subsystem: "com.timesafari.dailynotification", category: "fetch")
override func main() {
if isCancelled { return }
print("[DNP-FETCH] PrefetchOperation: starting fake fetch...")
fetchLogger.info("[DNP-FETCH] PrefetchOperation: starting fetch...")
// Simulate some work
// In real implementation, this would be:
// - Make HTTP request
// - Parse response
// - Cache content
// - Validate TTL and scheduled_for match notificationTime
// - Cache content (ensure persisted before completion)
// - Update database
// - Handle errors (network, auth, etc.)
// Simulate network delay
Thread.sleep(forTimeInterval: 2)
if isCancelled { return }
print("[DNP-FETCH] PrefetchOperation: finished fake fetch.")
// Simulate success/failure (in real code, check HTTP response)
let success = true // Replace with actual fetch result
if success {
fetchLogger.info("[DNP-FETCH] PrefetchOperation: fetch success")
// In real implementation: persist cache here before completion
} else {
isFailed = true
fetchLogger.error("[DNP-FETCH] PrefetchOperation: fetch failed")
}
}
}