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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user