1 // Copyright (c) 2020 650 Industries, Inc. All rights reserved. 2 3 import ExpoModulesTestCore 4 5 @testable import EXUpdates 6 7 import EXManifests 8 9 class MockErrorRecoveryDelegate: ErrorRecoveryDelegate { 10 public enum Method { 11 case relaunch 12 case loadRemoteUpdate 13 case markFailedLaunchForLaunchedUpdate 14 case markSuccessfulLaunchForLaunchedUpdate 15 case throwException 16 } 17 18 public var config: EXUpdates.UpdatesConfig 19 public var launchedUpdateToReturn: EXUpdates.Update? = nil 20 public var remoteLoadStatus: EXUpdates.RemoteLoadStatus = .Idle 21 22 private let relaunchCompletionParams: (Error?, Bool) 23 24 init(config: UpdatesConfig, relaunchCompletionParams: (Error?, Bool)) { 25 self.config = config 26 self.relaunchCompletionParams = relaunchCompletionParams 27 } 28 launchedUpdatenull29 func launchedUpdate() -> EXUpdates.Update? { 30 return launchedUpdateToReturn 31 } 32 33 private var callRecord: [Method: Int] = [:] verifynull34 public func verify(_ method: Method, times: Int = 1) { 35 let nTimes = self.callRecord[method] ?? 0 36 expect(nTimes).to(equal(times), description: "Method \(method) called \(nTimes) times, expected \(times)") 37 } nevernull38 public func never(_ method: Method) { 39 verify(method, times: 0) 40 } recordCallnull41 private func recordCall(method: Method) { 42 guard let currentCount = callRecord[method] else { 43 callRecord[method] = 1 44 return 45 } 46 callRecord[method] = currentCount + 1 47 } 48 49 func relaunch(completion: (Error?, Bool) -> Void) { 50 recordCall(method: .relaunch) 51 completion(relaunchCompletionParams.0, relaunchCompletionParams.1) 52 } 53 loadRemoteUpdatenull54 func loadRemoteUpdate() { 55 recordCall(method: .loadRemoteUpdate) 56 } 57 markFailedLaunchForLaunchedUpdatenull58 func markFailedLaunchForLaunchedUpdate() { 59 recordCall(method: .markFailedLaunchForLaunchedUpdate) 60 } 61 markSuccessfulLaunchForLaunchedUpdatenull62 func markSuccessfulLaunchForLaunchedUpdate() { 63 recordCall(method: .markSuccessfulLaunchForLaunchedUpdate) 64 } 65 throwExceptionnull66 func throwException(_ exception: NSException) { 67 recordCall(method: .throwException) 68 } 69 } 70 71 private extension DispatchQueue { flushnull72 func flush() { 73 self.sync { 74 // flush queue 75 } 76 } 77 } 78 79 class ErrorRecoverySpec : ExpoSpec { specnull80 override func spec() { 81 func setUp() -> (DispatchQueue, ErrorRecovery) { 82 let testQueue = DispatchQueue(label: "expo.errorRecoveryTestQueue") 83 return (testQueue, ErrorRecovery(errorRecoveryQueue: testQueue, diskWriteQueue: testQueue, remoteLoadTimeout: 500)) 84 } 85 86 describe("handleError") { 87 it("NewWorkingUpdateAlreadyLoaded") { 88 let (testQueue, errorRecovery) = setUp() 89 let mockDelegate = MockErrorRecoveryDelegate( 90 config: UpdatesConfig.config(fromDictionary: [:]), 91 relaunchCompletionParams: (nil, true) 92 ) 93 mockDelegate.remoteLoadStatus = .NewUpdateLoaded 94 errorRecovery.delegate = mockDelegate 95 96 let error = NSError(domain: "wat", code: 1) 97 errorRecovery.handle(error: error) 98 testQueue.flush() 99 100 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) 101 mockDelegate.verify(.relaunch) 102 mockDelegate.never(.loadRemoteUpdate) 103 mockDelegate.never(.throwException) 104 } 105 106 it("NewWorkingUpdateAlreadyLoaded_RCTContentDidAppear") { 107 let (testQueue, errorRecovery) = setUp() 108 let mockDelegate = MockErrorRecoveryDelegate( 109 config: UpdatesConfig.config(fromDictionary: [:]), 110 relaunchCompletionParams: (nil, true) 111 ) 112 mockDelegate.remoteLoadStatus = .NewUpdateLoaded 113 errorRecovery.delegate = mockDelegate 114 115 errorRecovery.startMonitoring() 116 NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) 117 118 mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) 119 120 let error = NSError(domain: "wat", code: 1) 121 errorRecovery.handle(error: error) 122 testQueue.flush() 123 124 mockDelegate.never(.relaunch) 125 mockDelegate.never(.loadRemoteUpdate) 126 mockDelegate.verify(.throwException) 127 } 128 129 it("NewUpdateLoaded_RelaunchFails") { 130 let (testQueue, errorRecovery) = setUp() 131 let mockDelegate = MockErrorRecoveryDelegate( 132 config: UpdatesConfig.config(fromDictionary: [:]), 133 relaunchCompletionParams: (NSError(domain: "huh", code: 123), false) 134 ) 135 mockDelegate.remoteLoadStatus = .NewUpdateLoaded 136 errorRecovery.delegate = mockDelegate 137 138 let error = NSError(domain: "wat", code: 1) 139 errorRecovery.handle(error: error) 140 testQueue.flush() 141 testQueue.flush() 142 143 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) 144 mockDelegate.verify(.relaunch) 145 mockDelegate.never(.loadRemoteUpdate) 146 mockDelegate.verify(.throwException) 147 } 148 149 // TODO(eric): make these tests less flaky on CI and reenable them 150 xit("NewWorkingUpdateLoading") { 151 let (testQueue, errorRecovery) = setUp() 152 let mockDelegate = MockErrorRecoveryDelegate( 153 config: UpdatesConfig.config(fromDictionary: [:]), 154 relaunchCompletionParams: (nil, true) 155 ) 156 mockDelegate.remoteLoadStatus = .Loading 157 errorRecovery.delegate = mockDelegate 158 159 let error = NSError(domain: "wat", code: 1) 160 errorRecovery.handle(error: error) 161 162 mockDelegate.remoteLoadStatus = .NewUpdateLoaded 163 errorRecovery.notify(newRemoteLoadStatus: .NewUpdateLoaded) 164 165 testQueue.flush() 166 167 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) 168 mockDelegate.verify(.relaunch) 169 mockDelegate.never(.loadRemoteUpdate) 170 mockDelegate.never(.throwException) 171 } 172 173 xit("NewWorkingUpdateLoading_RCTContentDidAppear") { 174 let (testQueue, errorRecovery) = setUp() 175 // should wait a short window for new update to load, then crash 176 let mockDelegate = MockErrorRecoveryDelegate( 177 config: UpdatesConfig.config(fromDictionary: [:]), 178 relaunchCompletionParams: (nil, true) 179 ) 180 mockDelegate.remoteLoadStatus = .Loading 181 errorRecovery.delegate = mockDelegate 182 183 errorRecovery.startMonitoring() 184 NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) 185 mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) 186 187 let error = NSError(domain: "wat", code: 1) 188 errorRecovery.handle(error: error) 189 190 // make sure we're waiting 191 Thread.sleep(forTimeInterval: 0.2) 192 testQueue.flush() 193 // don't throw yet! 194 mockDelegate.never(.throwException) 195 196 mockDelegate.remoteLoadStatus = .NewUpdateLoaded 197 errorRecovery.notify(newRemoteLoadStatus: .NewUpdateLoaded) 198 testQueue.flush() 199 200 mockDelegate.never(.relaunch) 201 mockDelegate.verify(.throwException) 202 } 203 204 it("NewBrokenUpdateLoaded_WorkingUpdateCached") { 205 let (testQueue, errorRecovery) = setUp() 206 let mockDelegate = MockErrorRecoveryDelegate( 207 config: UpdatesConfig.config(fromDictionary: [:]), 208 relaunchCompletionParams: (nil, true) 209 ) 210 mockDelegate.remoteLoadStatus = .NewUpdateLoaded 211 errorRecovery.delegate = mockDelegate 212 213 let error = NSError(domain: "wat", code: 1) 214 errorRecovery.handle(error: error) 215 testQueue.flush() 216 217 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) 218 mockDelegate.verify(.relaunch) 219 220 let error2 = NSError(domain: "wat", code: 1) 221 errorRecovery.handle(error: error2) 222 testQueue.flush() 223 224 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate, times: 2) 225 mockDelegate.verify(.relaunch, times: 2) 226 mockDelegate.never(.loadRemoteUpdate) 227 mockDelegate.never(.throwException) 228 } 229 230 it("NewBrokenUpdateLoaded_UpdateAlreadyLaunchedSuccessfully") { 231 let (testQueue, errorRecovery) = setUp() 232 // if an update has already been launched successfully, we don't want to fall back to an older update 233 234 let config = UpdatesConfig.config(fromDictionary: [:]) 235 let database = UpdatesDatabase() 236 let mockDelegate = MockErrorRecoveryDelegate( 237 config: config, 238 relaunchCompletionParams: (nil, true) 239 ) 240 mockDelegate.remoteLoadStatus = .NewUpdateLoaded 241 242 let mockUpdate = Update( 243 manifest: ManifestFactory.manifest(forManifestJSON: [:]), 244 config: config, 245 database: database, 246 updateId: UUID(), 247 scopeKey: "wat", 248 commitTime: Date(), 249 runtimeVersion: "1.0", 250 keep: true, 251 status: .Status0_Unused, 252 isDevelopmentMode: false, 253 assetsFromManifest: [] 254 ) 255 mockUpdate.successfulLaunchCount = 1 256 257 mockDelegate.launchedUpdateToReturn = mockUpdate 258 errorRecovery.delegate = mockDelegate 259 260 let error = NSError(domain: "wat", code: 1) 261 errorRecovery.handle(error: error) 262 testQueue.flush() 263 264 mockDelegate.never(.markFailedLaunchForLaunchedUpdate) 265 mockDelegate.verify(.relaunch) 266 267 mockUpdate.successfulLaunchCount = 0 268 let error2 = NSError(domain: "wat", code: 1) 269 errorRecovery.handle(error: error2) 270 testQueue.flush() 271 272 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) 273 mockDelegate.verify(.throwException) 274 mockDelegate.never(.loadRemoteUpdate) 275 } 276 277 // TODO(eric): make these tests less flaky on CI and reenable them 278 xit("RemoteLoadTimesOut") { 279 let (testQueue, errorRecovery) = setUp() 280 let mockDelegate = MockErrorRecoveryDelegate( 281 config: UpdatesConfig.config(fromDictionary: [:]), 282 relaunchCompletionParams: (nil, true) 283 ) 284 mockDelegate.remoteLoadStatus = .Loading 285 errorRecovery.delegate = mockDelegate 286 287 let error = NSError(domain: "wat", code: 1) 288 errorRecovery.handle(error: error) 289 290 // wait for more than 500ms 291 Thread.sleep(forTimeInterval: 0.6) 292 testQueue.flush() 293 294 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) 295 mockDelegate.verify(.relaunch) 296 mockDelegate.never(.loadRemoteUpdate) 297 mockDelegate.never(.throwException) 298 } 299 300 xit("RemoteLoadTimesOut_UpdateAlreadyLaunchedSuccessfully") { 301 let (testQueue, errorRecovery) = setUp() 302 // if an update has already been launched successfully, we don't want to fall back to an older update 303 let config = UpdatesConfig.config(fromDictionary: [:]) 304 let database = UpdatesDatabase() 305 let mockDelegate = MockErrorRecoveryDelegate( 306 config: config, 307 relaunchCompletionParams: (nil, true) 308 ) 309 mockDelegate.remoteLoadStatus = .Loading 310 errorRecovery.delegate = mockDelegate 311 312 let mockUpdate = Update( 313 manifest: ManifestFactory.manifest(forManifestJSON: [:]), 314 config: config, 315 database: database, 316 updateId: UUID(), 317 scopeKey: "wat", 318 commitTime: Date(), 319 runtimeVersion: "1.0", 320 keep: true, 321 status: .Status0_Unused, 322 isDevelopmentMode: false, 323 assetsFromManifest: [] 324 ) 325 mockUpdate.successfulLaunchCount = 1 326 327 mockDelegate.launchedUpdateToReturn = mockUpdate 328 errorRecovery.delegate = mockDelegate 329 330 let error = NSError(domain: "wat", code: 1) 331 errorRecovery.handle(error: error) 332 333 // wait for more than 500ms 334 Thread.sleep(forTimeInterval: 0.6) 335 testQueue.flush() 336 337 mockDelegate.verify(.throwException) 338 mockDelegate.never(.markFailedLaunchForLaunchedUpdate) 339 mockDelegate.never(.relaunch) 340 mockDelegate.never(.loadRemoteUpdate) 341 } 342 343 xit("RemoteLoadTimesOut_RCTContentDidAppear") { 344 let (testQueue, errorRecovery) = setUp() 345 let mockDelegate = MockErrorRecoveryDelegate( 346 config: UpdatesConfig.config(fromDictionary: [:]), 347 relaunchCompletionParams: (nil, true) 348 ) 349 mockDelegate.remoteLoadStatus = .Loading 350 errorRecovery.delegate = mockDelegate 351 352 errorRecovery.startMonitoring() 353 NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) 354 mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) 355 356 // if RCTContentDidAppear has already fired, we don't want to roll back to an older update 357 let error = NSError(domain: "wat", code: 1) 358 errorRecovery.handle(error: error) 359 360 // wait for more than 500ms 361 Thread.sleep(forTimeInterval: 0.6) 362 testQueue.flush() 363 364 mockDelegate.verify(.throwException) 365 mockDelegate.never(.markFailedLaunchForLaunchedUpdate) 366 mockDelegate.never(.relaunch) 367 mockDelegate.never(.loadRemoteUpdate) 368 } 369 370 it("NoRemoteUpdate") { 371 let (testQueue, errorRecovery) = setUp() 372 let mockDelegate = MockErrorRecoveryDelegate( 373 config: UpdatesConfig.config(fromDictionary: [:]), 374 relaunchCompletionParams: (nil, true) 375 ) 376 mockDelegate.remoteLoadStatus = .Idle 377 errorRecovery.delegate = mockDelegate 378 379 let error = NSError(domain: "wat", code: 1) 380 errorRecovery.handle(error: error) 381 testQueue.flush() 382 383 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) 384 // should try to load a remote update since we don't have one already 385 mockDelegate.verify(.loadRemoteUpdate) 386 387 // indicate there isn't a new update from the server 388 errorRecovery.notify(newRemoteLoadStatus: .Idle) 389 testQueue.flush() 390 mockDelegate.verify(.relaunch) 391 } 392 393 it("NoRemoteUpdate_RCTContentDidAppear") { 394 let (testQueue, errorRecovery) = setUp() 395 let mockDelegate = MockErrorRecoveryDelegate( 396 config: UpdatesConfig.config(fromDictionary: [:]), 397 relaunchCompletionParams: (nil, true) 398 ) 399 mockDelegate.remoteLoadStatus = .Idle 400 errorRecovery.delegate = mockDelegate 401 402 errorRecovery.startMonitoring() 403 NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) 404 mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) 405 406 let error = NSError(domain: "wat", code: 1) 407 errorRecovery.handle(error: error) 408 testQueue.flush() 409 410 // should try to load a remote update since we don't have one already 411 mockDelegate.verify(.loadRemoteUpdate) 412 413 // indicate there isn't a new update from the server 414 errorRecovery.notify(newRemoteLoadStatus: .Idle) 415 testQueue.flush() 416 mockDelegate.verify(.throwException) 417 } 418 419 it("CheckAutomaticallyNever") { 420 let (testQueue, errorRecovery) = setUp() 421 let config = UpdatesConfig.config(fromDictionary: [ 422 UpdatesConfig.EXUpdatesConfigCheckOnLaunchKey: UpdatesConfig.EXUpdatesConfigCheckOnLaunchValueNever 423 ]) 424 let mockDelegate = MockErrorRecoveryDelegate( 425 config: config, 426 relaunchCompletionParams: (nil, true) 427 ) 428 mockDelegate.remoteLoadStatus = .Idle 429 errorRecovery.delegate = mockDelegate 430 431 let error = NSError(domain: "wat", code: 1) 432 errorRecovery.handle(error: error) 433 testQueue.flush() 434 435 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) 436 mockDelegate.verify(.relaunch) 437 } 438 439 it("CheckAutomaticallyNever_RCTContentDidAppear") { 440 let (testQueue, errorRecovery) = setUp() 441 let config = UpdatesConfig.config(fromDictionary: [ 442 UpdatesConfig.EXUpdatesConfigCheckOnLaunchKey: UpdatesConfig.EXUpdatesConfigCheckOnLaunchValueNever 443 ]) 444 let mockDelegate = MockErrorRecoveryDelegate( 445 config: config, 446 relaunchCompletionParams: (nil, true) 447 ) 448 mockDelegate.remoteLoadStatus = .Idle 449 errorRecovery.delegate = mockDelegate 450 451 errorRecovery.startMonitoring() 452 NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) 453 mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) 454 455 let error = NSError(domain: "wat", code: 1) 456 errorRecovery.handle(error: error) 457 testQueue.flush() 458 459 mockDelegate.verify(.throwException) 460 } 461 } 462 463 describe("multiple errors") { 464 it("handles two errors") { 465 let (testQueue, errorRecovery) = setUp() 466 let mockDelegate = MockErrorRecoveryDelegate( 467 config: UpdatesConfig.config(fromDictionary: [:]), 468 relaunchCompletionParams: (nil, true) 469 ) 470 mockDelegate.remoteLoadStatus = .Idle 471 errorRecovery.delegate = mockDelegate 472 473 let error = NSError(domain: "wat", code: 1) 474 errorRecovery.handle(error: error) 475 errorRecovery.handle(error: error) 476 testQueue.flush() 477 478 // the actual error recovery should only happen once despite there being two errors 479 mockDelegate.verify(.loadRemoteUpdate, times: 1) 480 } 481 } 482 483 describe("exceptions") { 484 it("handles exceptions") { 485 let (testQueue, errorRecovery) = setUp() 486 let mockDelegate = MockErrorRecoveryDelegate( 487 config: UpdatesConfig.config(fromDictionary: [:]), 488 relaunchCompletionParams: (nil, true) 489 ) 490 mockDelegate.remoteLoadStatus = .NewUpdateLoaded 491 errorRecovery.delegate = mockDelegate 492 493 let testException = NSException(name: NSExceptionName.genericException, reason: "wat") 494 errorRecovery.handle(exception: testException) 495 testQueue.flush() 496 497 mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) 498 mockDelegate.verify(.relaunch) 499 mockDelegate.never(.loadRemoteUpdate) 500 mockDelegate.never(.throwException) 501 } 502 } 503 504 describe("error log") { 505 it("consume") { 506 let (testQueue, errorRecovery) = setUp() 507 // start with a clean slate 508 _ = ErrorRecovery.consumeErrorLog() 509 510 let error = NSError(domain: "TestDomain", code: 47, userInfo: [NSLocalizedDescriptionKey: "TestLocalizedDescription"]) 511 errorRecovery.writeErrorOrExceptionToLog(error) 512 testQueue.flush() 513 514 let errorLog = ErrorRecovery.consumeErrorLog() 515 expect(errorLog?.contains("TestDomain")) == true 516 expect(errorLog?.contains("47")) == true 517 expect(errorLog?.contains("TestLocalizedDescription")) == true 518 } 519 520 it("consume multiple errors") { 521 let (testQueue, errorRecovery) = setUp() 522 // start with a clean slate 523 _ = ErrorRecovery.consumeErrorLog() 524 525 let error = NSError(domain: "TestDomain", code: 47, userInfo: [NSLocalizedDescriptionKey: "TestLocalizedDescription"]) 526 errorRecovery.writeErrorOrExceptionToLog(error) 527 528 let exception = NSException(name: NSExceptionName(rawValue: "TestName"), reason: "TestReason") 529 errorRecovery.writeErrorOrExceptionToLog(exception) 530 testQueue.flush() 531 532 let errorLog = ErrorRecovery.consumeErrorLog() 533 expect(errorLog?.contains("TestDomain")) == true 534 expect(errorLog?.contains("47")) == true 535 expect(errorLog?.contains("TestLocalizedDescription")) == true 536 expect(errorLog?.contains("TestName")) == true 537 expect(errorLog?.contains("TestReason")) == true 538 } 539 } 540 } 541 } 542