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