From 02d1e2a0aa85baf5d388bd161a54c0218487be7a Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sun, 22 Feb 2026 02:12:45 +0100 Subject: [PATCH] [iOS][updates] Migrate tests to Swift Testing (2/3) (#42753) --- .../Tests/AppLauncherWithDatabaseTests.swift | 1 + .../Tests/CodeSigningConfigurationTests.swift | 3 + .../Tests/DatabaseInitializationTests.swift | 3 + .../ios/Tests/ErrorRecoverySpec.swift | 592 ----------------- .../ios/Tests/ErrorRecoveryTests.swift | 606 ++++++++++++++++++ .../ios/Tests/FileDownloaderTests.swift | 1 + .../ios/Tests/HermesDiffSpec.swift | 68 -- .../ios/Tests/HermesDiffTests.swift | 69 ++ .../ios/Tests/NewUpdateSpec.swift | 117 ---- .../ios/Tests/NewUpdateTests.swift | 138 ++++ .../ios/Tests/ResponseHeaderDataSpec.swift | 54 -- .../ios/Tests/ResponseHeaderDataTests.swift | 56 ++ .../SelectionPolicyFilterAwareSpec.swift | 274 -------- .../SelectionPolicyFilterAwareTests.swift | 290 +++++++++ .../ios/Tests/SignatureHeaderInfoSpec.swift | 39 -- .../ios/Tests/SignatureHeaderInfoTests.swift | 54 ++ .../ios/Tests/StringDictionarySpec.swift | 40 -- .../ios/Tests/StringDictionaryTests.swift | 54 ++ .../ios/Tests/StringItemSpec.swift | 25 - .../ios/Tests/StringItemTests.swift | 32 + .../ios/Tests/StringListSpec.swift | 19 - .../ios/Tests/StringListTests.swift | 18 + .../ios/Tests/UpdateAssetSpec.swift | 40 -- .../ios/Tests/UpdateAssetTests.swift | 40 ++ .../expo-updates/ios/Tests/UpdateSpec.swift | 106 --- .../expo-updates/ios/Tests/UpdateTests.swift | 116 ++++ .../ios/Tests/UpdatesConfigSpec.swift | 141 ---- .../ios/Tests/UpdatesConfigTests.swift | 149 +++++ .../ios/Tests/UpdatesLogReaderSpec.swift | 87 --- .../ios/Tests/UpdatesLogReaderTests.swift | 87 +++ ...> UpdatesMultipartStreamReaderTests.swift} | 91 ++- .../Tests/UpdatesParameterParserSpec.swift | 53 -- .../Tests/UpdatesParameterParserTests.swift | 55 ++ 33 files changed, 1814 insertions(+), 1704 deletions(-) delete mode 100644 packages/expo-updates/ios/Tests/ErrorRecoverySpec.swift create mode 100644 packages/expo-updates/ios/Tests/ErrorRecoveryTests.swift delete mode 100644 packages/expo-updates/ios/Tests/HermesDiffSpec.swift create mode 100644 packages/expo-updates/ios/Tests/HermesDiffTests.swift delete mode 100644 packages/expo-updates/ios/Tests/NewUpdateSpec.swift create mode 100644 packages/expo-updates/ios/Tests/NewUpdateTests.swift delete mode 100644 packages/expo-updates/ios/Tests/ResponseHeaderDataSpec.swift create mode 100644 packages/expo-updates/ios/Tests/ResponseHeaderDataTests.swift delete mode 100644 packages/expo-updates/ios/Tests/SelectionPolicyFilterAwareSpec.swift create mode 100644 packages/expo-updates/ios/Tests/SelectionPolicyFilterAwareTests.swift delete mode 100644 packages/expo-updates/ios/Tests/SignatureHeaderInfoSpec.swift create mode 100644 packages/expo-updates/ios/Tests/SignatureHeaderInfoTests.swift delete mode 100644 packages/expo-updates/ios/Tests/StringDictionarySpec.swift create mode 100644 packages/expo-updates/ios/Tests/StringDictionaryTests.swift delete mode 100644 packages/expo-updates/ios/Tests/StringItemSpec.swift create mode 100644 packages/expo-updates/ios/Tests/StringItemTests.swift delete mode 100644 packages/expo-updates/ios/Tests/StringListSpec.swift create mode 100644 packages/expo-updates/ios/Tests/StringListTests.swift delete mode 100644 packages/expo-updates/ios/Tests/UpdateAssetSpec.swift create mode 100644 packages/expo-updates/ios/Tests/UpdateAssetTests.swift delete mode 100644 packages/expo-updates/ios/Tests/UpdateSpec.swift create mode 100644 packages/expo-updates/ios/Tests/UpdateTests.swift delete mode 100644 packages/expo-updates/ios/Tests/UpdatesConfigSpec.swift create mode 100644 packages/expo-updates/ios/Tests/UpdatesConfigTests.swift delete mode 100644 packages/expo-updates/ios/Tests/UpdatesLogReaderSpec.swift create mode 100644 packages/expo-updates/ios/Tests/UpdatesLogReaderTests.swift rename packages/expo-updates/ios/Tests/{UpdatesMultipartStreamReaderSpec.swift => UpdatesMultipartStreamReaderTests.swift} (57%) delete mode 100644 packages/expo-updates/ios/Tests/UpdatesParameterParserSpec.swift create mode 100644 packages/expo-updates/ios/Tests/UpdatesParameterParserTests.swift diff --git a/packages/expo-updates/ios/Tests/AppLauncherWithDatabaseTests.swift b/packages/expo-updates/ios/Tests/AppLauncherWithDatabaseTests.swift index 59292dd5768867..30c468e0409d53 100644 --- a/packages/expo-updates/ios/Tests/AppLauncherWithDatabaseTests.swift +++ b/packages/expo-updates/ios/Tests/AppLauncherWithDatabaseTests.swift @@ -40,6 +40,7 @@ class AppLauncherWithDatabaseMock: AppLauncherWithDatabase { } @Suite("AppLauncherWithDatabase", .serialized) +@MainActor class AppLauncherWithDatabaseTests { var testDatabaseDir: URL var db: UpdatesDatabase diff --git a/packages/expo-updates/ios/Tests/CodeSigningConfigurationTests.swift b/packages/expo-updates/ios/Tests/CodeSigningConfigurationTests.swift index a6140b0aee5365..976def2aa087f8 100644 --- a/packages/expo-updates/ios/Tests/CodeSigningConfigurationTests.swift +++ b/packages/expo-updates/ios/Tests/CodeSigningConfigurationTests.swift @@ -5,6 +5,7 @@ import Testing @testable import EXUpdates @Suite("CodeSigningConfiguration") +@MainActor struct CodeSigningConfigurationTests { @Test func `works with separate certificate chain`() { @@ -32,6 +33,7 @@ struct CodeSigningConfigurationTests { // MARK: - createAcceptSignatureHeader @Suite("createAcceptSignatureHeader") + @MainActor struct CreateAcceptSignatureHeaderTests { @Test func `creates signature header default values`() throws { @@ -102,6 +104,7 @@ struct CodeSigningConfigurationTests { // MARK: - validateSignature @Suite("validateSignature") + @MainActor struct ValidateSignatureTests { @Test func `works for valid case`() throws { diff --git a/packages/expo-updates/ios/Tests/DatabaseInitializationTests.swift b/packages/expo-updates/ios/Tests/DatabaseInitializationTests.swift index d7b37b8b02d7c8..5c00ec216a4d58 100644 --- a/packages/expo-updates/ios/Tests/DatabaseInitializationTests.swift +++ b/packages/expo-updates/ios/Tests/DatabaseInitializationTests.swift @@ -336,6 +336,7 @@ let UpdatesDatabaseV10Schema = """ """ @Suite("UpdatesDatabaseInitialization", .serialized) +@MainActor struct DatabaseInitializationTests { var testDatabaseDir: URL @@ -353,6 +354,7 @@ struct DatabaseInitializationTests { // MARK: - Database persistence @Suite("database persistence", .serialized) + @MainActor struct DatabasePersistenceTests { var testDatabaseDir: URL @@ -399,6 +401,7 @@ struct DatabaseInitializationTests { // MARK: - Migrations @Suite("migrations", .serialized) + @MainActor struct MigrationsTests { var testDatabaseDir: URL diff --git a/packages/expo-updates/ios/Tests/ErrorRecoverySpec.swift b/packages/expo-updates/ios/Tests/ErrorRecoverySpec.swift deleted file mode 100644 index ba7382f14a4288..00000000000000 --- a/packages/expo-updates/ios/Tests/ErrorRecoverySpec.swift +++ /dev/null @@ -1,592 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -import EXManifests - -class MockErrorRecoveryDelegate: ErrorRecoveryDelegate { - public enum Method { - case relaunch - case loadRemoteUpdate - case markFailedLaunchForLaunchedUpdate - case markSuccessfulLaunchForLaunchedUpdate - case throwException - } - - public var config: EXUpdates.UpdatesConfig - public var launchedUpdateToReturn: EXUpdates.Update? = nil - public var remoteLoadStatus: EXUpdates.RemoteLoadStatus = .Idle - - private let relaunchCompletionParams: (Error?, Bool) - - init(config: UpdatesConfig, relaunchCompletionParams: (Error?, Bool)) { - self.config = config - self.relaunchCompletionParams = relaunchCompletionParams - } - - func launchedUpdate() -> EXUpdates.Update? { - return launchedUpdateToReturn - } - - private var callRecord: [Method: Int] = [:] - public func verify(_ method: Method, times: Int = 1) { - let nTimes = self.callRecord[method] ?? 0 - expect(nTimes).to(equal(times), description: "Method \(method) called \(nTimes) times, expected \(times)") - } - public func never(_ method: Method) { - verify(method, times: 0) - } - private func recordCall(method: Method) { - guard let currentCount = callRecord[method] else { - callRecord[method] = 1 - return - } - callRecord[method] = currentCount + 1 - } - - func relaunch(completion: (Error?, Bool) -> Void) { - recordCall(method: .relaunch) - completion(relaunchCompletionParams.0, relaunchCompletionParams.1) - } - - func loadRemoteUpdate() { - recordCall(method: .loadRemoteUpdate) - } - - func markFailedLaunchForLaunchedUpdate() { - recordCall(method: .markFailedLaunchForLaunchedUpdate) - } - - func markSuccessfulLaunchForLaunchedUpdate() { - recordCall(method: .markSuccessfulLaunchForLaunchedUpdate) - } - - func throwException(_ exception: NSException) { - recordCall(method: .throwException) - } -} - -private extension DispatchQueue { - func flush() { - self.sync { - // flush queue - } - } -} - -class ErrorRecoverySpec : ExpoSpec { - override class func spec() { - func setUp() -> (DispatchQueue, ErrorRecovery) { - let testQueue = DispatchQueue(label: "expo.errorRecoveryTestQueue") - return (testQueue, ErrorRecovery(logger: UpdatesLogger(), errorRecoveryQueue: testQueue, remoteLoadTimeout: 500)) - } - - describe("handleError") { - it("NewWorkingUpdateAlreadyLoaded") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .NewUpdateLoaded - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) - mockDelegate.verify(.relaunch) - mockDelegate.never(.loadRemoteUpdate) - mockDelegate.never(.throwException) - } - - it("NewWorkingUpdateAlreadyLoaded_RCTContentDidAppear") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .NewUpdateLoaded - errorRecovery.delegate = mockDelegate - - errorRecovery.startMonitoring() - NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) - - mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - testQueue.flush() - - mockDelegate.never(.relaunch) - mockDelegate.never(.loadRemoteUpdate) - mockDelegate.verify(.throwException) - } - - it("NewUpdateLoaded_RelaunchFails") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (NSError(domain: "huh", code: 123), false) - ) - mockDelegate.remoteLoadStatus = .NewUpdateLoaded - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - testQueue.flush() - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) - mockDelegate.verify(.relaunch) - mockDelegate.never(.loadRemoteUpdate) - mockDelegate.verify(.throwException) - } - - // TODO(eric): make these tests less flaky on CI and reenable them - xit("NewWorkingUpdateLoading") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Loading - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - - mockDelegate.remoteLoadStatus = .NewUpdateLoaded - errorRecovery.notify(newRemoteLoadStatus: .NewUpdateLoaded) - - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) - mockDelegate.verify(.relaunch) - mockDelegate.never(.loadRemoteUpdate) - mockDelegate.never(.throwException) - } - - xit("NewWorkingUpdateLoading_RCTContentDidAppear") { - let (testQueue, errorRecovery) = setUp() - // should wait a short window for new update to load, then crash - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Loading - errorRecovery.delegate = mockDelegate - - errorRecovery.startMonitoring() - NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) - mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - - // make sure we're waiting - Thread.sleep(forTimeInterval: 0.2) - testQueue.flush() - // don't throw yet! - mockDelegate.never(.throwException) - - mockDelegate.remoteLoadStatus = .NewUpdateLoaded - errorRecovery.notify(newRemoteLoadStatus: .NewUpdateLoaded) - testQueue.flush() - - mockDelegate.never(.relaunch) - mockDelegate.verify(.throwException) - } - - it("NewBrokenUpdateLoaded_WorkingUpdateCached") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .NewUpdateLoaded - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) - mockDelegate.verify(.relaunch) - - let error2 = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error2) - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate, times: 2) - mockDelegate.verify(.relaunch, times: 2) - mockDelegate.never(.loadRemoteUpdate) - mockDelegate.never(.throwException) - } - - it("NewBrokenUpdateLoaded_UpdateAlreadyLaunchedSuccessfully") { - let (testQueue, errorRecovery) = setUp() - // if an update has already been launched successfully, we don't want to fall back to an older update - - let config = try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]) - let database = UpdatesDatabase() - let mockDelegate = MockErrorRecoveryDelegate( - config: config, - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .NewUpdateLoaded - - let mockUpdate = Update( - manifest: ManifestFactory.manifest(forManifestJSON: [:]), - config: config, - database: database, - updateId: UUID(), - scopeKey: "wat", - commitTime: Date(), - runtimeVersion: "1.0", - keep: true, - status: .Status0_Unused, - isDevelopmentMode: false, - assetsFromManifest: [], - url: URL(string: "https://example.com"), - requestHeaders: [:] - ) - mockUpdate.successfulLaunchCount = 1 - - mockDelegate.launchedUpdateToReturn = mockUpdate - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - testQueue.flush() - - mockDelegate.never(.markFailedLaunchForLaunchedUpdate) - mockDelegate.verify(.relaunch) - - mockUpdate.successfulLaunchCount = 0 - let error2 = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error2) - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) - mockDelegate.verify(.throwException) - mockDelegate.never(.loadRemoteUpdate) - } - - // TODO(eric): make these tests less flaky on CI and reenable them - xit("RemoteLoadTimesOut") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Loading - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - - // wait for more than 500ms - Thread.sleep(forTimeInterval: 0.6) - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) - mockDelegate.verify(.relaunch) - mockDelegate.never(.loadRemoteUpdate) - mockDelegate.never(.throwException) - } - - xit("RemoteLoadTimesOut_UpdateAlreadyLaunchedSuccessfully") { - let (testQueue, errorRecovery) = setUp() - // if an update has already been launched successfully, we don't want to fall back to an older update - let config = try! UpdatesConfig.config(fromDictionary: [:]) - let database = UpdatesDatabase() - let mockDelegate = MockErrorRecoveryDelegate( - config: config, - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Loading - errorRecovery.delegate = mockDelegate - - let mockUpdate = Update( - manifest: ManifestFactory.manifest(forManifestJSON: [:]), - config: config, - database: database, - updateId: UUID(), - scopeKey: "wat", - commitTime: Date(), - runtimeVersion: "1.0", - keep: true, - status: .Status0_Unused, - isDevelopmentMode: false, - assetsFromManifest: [], - url: nil, - requestHeaders: [:] - ) - mockUpdate.successfulLaunchCount = 1 - - mockDelegate.launchedUpdateToReturn = mockUpdate - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - - // wait for more than 500ms - Thread.sleep(forTimeInterval: 0.6) - testQueue.flush() - - mockDelegate.verify(.throwException) - mockDelegate.never(.markFailedLaunchForLaunchedUpdate) - mockDelegate.never(.relaunch) - mockDelegate.never(.loadRemoteUpdate) - } - - xit("RemoteLoadTimesOut_RCTContentDidAppear") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Loading - errorRecovery.delegate = mockDelegate - - errorRecovery.startMonitoring() - NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) - mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) - - // if RCTContentDidAppear has already fired, we don't want to roll back to an older update - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - - // wait for more than 500ms - Thread.sleep(forTimeInterval: 0.6) - testQueue.flush() - - mockDelegate.verify(.throwException) - mockDelegate.never(.markFailedLaunchForLaunchedUpdate) - mockDelegate.never(.relaunch) - mockDelegate.never(.loadRemoteUpdate) - } - - it("NoRemoteUpdate") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Idle - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) - // should try to load a remote update since we don't have one already - mockDelegate.verify(.loadRemoteUpdate) - - // indicate there isn't a new update from the server - errorRecovery.notify(newRemoteLoadStatus: .Idle) - testQueue.flush() - mockDelegate.verify(.relaunch) - } - - it("NoRemoteUpdate_RCTContentDidAppear") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Idle - errorRecovery.delegate = mockDelegate - - errorRecovery.startMonitoring() - NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) - mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - testQueue.flush() - - // should try to load a remote update since we don't have one already - mockDelegate.verify(.loadRemoteUpdate) - - // indicate there isn't a new update from the server - errorRecovery.notify(newRemoteLoadStatus: .Idle) - testQueue.flush() - mockDelegate.verify(.throwException) - } - - it("CheckAutomaticallyNever") { - let (testQueue, errorRecovery) = setUp() - let config = try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigCheckOnLaunchKey: UpdatesConfig.EXUpdatesConfigCheckOnLaunchValueNever, - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]) - let mockDelegate = MockErrorRecoveryDelegate( - config: config, - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Idle - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) - mockDelegate.verify(.relaunch) - } - - it("CheckAutomaticallyNever_RCTContentDidAppear") { - let (testQueue, errorRecovery) = setUp() - let config = try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigCheckOnLaunchKey: UpdatesConfig.EXUpdatesConfigCheckOnLaunchValueNever, - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]) - let mockDelegate = MockErrorRecoveryDelegate( - config: config, - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Idle - errorRecovery.delegate = mockDelegate - - errorRecovery.startMonitoring() - NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) - mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate) - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - testQueue.flush() - - mockDelegate.verify(.throwException) - } - } - - describe("multiple errors") { - it("handles two errors") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .Idle - errorRecovery.delegate = mockDelegate - - let error = NSError(domain: "wat", code: 1) - errorRecovery.handle(error: error) - errorRecovery.handle(error: error) - testQueue.flush() - - // the actual error recovery should only happen once despite there being two errors - mockDelegate.verify(.loadRemoteUpdate, times: 1) - } - } - - describe("exceptions") { - it("handles exceptions") { - let (testQueue, errorRecovery) = setUp() - let mockDelegate = MockErrorRecoveryDelegate( - config: try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]), - relaunchCompletionParams: (nil, true) - ) - mockDelegate.remoteLoadStatus = .NewUpdateLoaded - errorRecovery.delegate = mockDelegate - - let testException = NSException(name: NSExceptionName.genericException, reason: "wat") - errorRecovery.handle(exception: testException) - testQueue.flush() - - mockDelegate.verify(.markFailedLaunchForLaunchedUpdate) - mockDelegate.verify(.relaunch) - mockDelegate.never(.loadRemoteUpdate) - mockDelegate.never(.throwException) - } - } - - describe("error log") { - it("consume") { - let logger = UpdatesLogger() - let (testQueue, _) = setUp() - // start with a clean slate - _ = ErrorRecovery.consumeErrorLog(logger: logger) - - let error = NSError(domain: "TestDomain", code: 47, userInfo: [NSLocalizedDescriptionKey: "TestLocalizedDescription"]) - ErrorRecovery.writeErrorOrExceptionToLog(error, logger, dispatchQueue: testQueue) - testQueue.flush() - DispatchQueue.global().flush() - - let errorLog = ErrorRecovery.consumeErrorLog(logger: logger) - expect(errorLog?.contains("TestDomain")) == true - expect(errorLog?.contains("47")) == true - expect(errorLog?.contains("TestLocalizedDescription")) == true - } - - it("consume multiple errors") { - let logger = UpdatesLogger() - let (testQueue, _) = setUp() - // start with a clean slate - _ = ErrorRecovery.consumeErrorLog(logger: logger) - - let error = NSError(domain: "TestDomain", code: 47, userInfo: [NSLocalizedDescriptionKey: "TestLocalizedDescription"]) - ErrorRecovery.writeErrorOrExceptionToLog(error, logger, dispatchQueue: testQueue) - - let exception = NSException(name: NSExceptionName(rawValue: "TestName"), reason: "TestReason") - ErrorRecovery.writeErrorOrExceptionToLog(exception, logger, dispatchQueue: testQueue) - testQueue.flush() - DispatchQueue.global().flush() - - let errorLog = ErrorRecovery.consumeErrorLog(logger: logger) - expect(errorLog?.contains("TestDomain")) == true - expect(errorLog?.contains("47")) == true - expect(errorLog?.contains("TestLocalizedDescription")) == true - expect(errorLog?.contains("TestName")) == true - expect(errorLog?.contains("TestReason")) == true - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/ErrorRecoveryTests.swift b/packages/expo-updates/ios/Tests/ErrorRecoveryTests.swift new file mode 100644 index 00000000000000..092a20c50117e6 --- /dev/null +++ b/packages/expo-updates/ios/Tests/ErrorRecoveryTests.swift @@ -0,0 +1,606 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +import EXManifests + +class MockErrorRecoveryDelegate: ErrorRecoveryDelegate { + public enum Method { + case relaunch + case loadRemoteUpdate + case markFailedLaunchForLaunchedUpdate + case markSuccessfulLaunchForLaunchedUpdate + case throwException + } + + public var config: EXUpdates.UpdatesConfig + public var launchedUpdateToReturn: EXUpdates.Update? = nil + public var remoteLoadStatus: EXUpdates.RemoteLoadStatus = .Idle + + private let relaunchCompletionParams: (Error?, Bool) + + init(config: UpdatesConfig, relaunchCompletionParams: (Error?, Bool)) { + self.config = config + self.relaunchCompletionParams = relaunchCompletionParams + } + + func launchedUpdate() -> EXUpdates.Update? { + return launchedUpdateToReturn + } + + private var callRecord: [Method: Int] = [:] + + public func verifyCount(_ method: Method) -> Int { + return callRecord[method] ?? 0 + } + + private func recordCall(method: Method) { + guard let currentCount = callRecord[method] else { + callRecord[method] = 1 + return + } + callRecord[method] = currentCount + 1 + } + + func relaunch(completion: (Error?, Bool) -> Void) { + recordCall(method: .relaunch) + completion(relaunchCompletionParams.0, relaunchCompletionParams.1) + } + + func loadRemoteUpdate() { + recordCall(method: .loadRemoteUpdate) + } + + func markFailedLaunchForLaunchedUpdate() { + recordCall(method: .markFailedLaunchForLaunchedUpdate) + } + + func markSuccessfulLaunchForLaunchedUpdate() { + recordCall(method: .markSuccessfulLaunchForLaunchedUpdate) + } + + func throwException(_ exception: NSException) { + recordCall(method: .throwException) + } +} + +private extension DispatchQueue { + func flush() { + self.sync { + // flush queue + } + } +} + +@Suite("ErrorRecovery", .serialized) +@MainActor +struct ErrorRecoveryTests { + func setUp() -> (DispatchQueue, ErrorRecovery) { + let testQueue = DispatchQueue(label: "expo.errorRecoveryTestQueue") + return (testQueue, ErrorRecovery(logger: UpdatesLogger(), errorRecoveryQueue: testQueue, remoteLoadTimeout: 500)) + } + + // MARK: - handleError + + @Test + func `NewWorkingUpdateAlreadyLoaded`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .NewUpdateLoaded + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 1) + #expect(mockDelegate.verifyCount(.relaunch) == 1) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + #expect(mockDelegate.verifyCount(.throwException) == 0) + } + + @Test + func `NewWorkingUpdateAlreadyLoaded_RCTContentDidAppear`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .NewUpdateLoaded + errorRecovery.delegate = mockDelegate + + errorRecovery.startMonitoring() + NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) + + #expect(mockDelegate.verifyCount(.markSuccessfulLaunchForLaunchedUpdate) == 1) + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.relaunch) == 0) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + #expect(mockDelegate.verifyCount(.throwException) == 1) + } + + @Test + func `NewUpdateLoaded_RelaunchFails`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (NSError(domain: "huh", code: 123), false) + ) + mockDelegate.remoteLoadStatus = .NewUpdateLoaded + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + testQueue.flush() + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 1) + #expect(mockDelegate.verifyCount(.relaunch) == 1) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + #expect(mockDelegate.verifyCount(.throwException) == 1) + } + + @Test(.disabled("flaky on CI")) + func `NewWorkingUpdateLoading`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Loading + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + + mockDelegate.remoteLoadStatus = .NewUpdateLoaded + errorRecovery.notify(newRemoteLoadStatus: .NewUpdateLoaded) + + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 1) + #expect(mockDelegate.verifyCount(.relaunch) == 1) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + #expect(mockDelegate.verifyCount(.throwException) == 0) + } + + @Test(.disabled("flaky on CI")) + func `NewWorkingUpdateLoading_RCTContentDidAppear`() throws { + let (testQueue, errorRecovery) = setUp() + // should wait a short window for new update to load, then crash + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Loading + errorRecovery.delegate = mockDelegate + + errorRecovery.startMonitoring() + NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) + #expect(mockDelegate.verifyCount(.markSuccessfulLaunchForLaunchedUpdate) == 1) + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + + // make sure we're waiting + Thread.sleep(forTimeInterval: 0.2) + testQueue.flush() + // don't throw yet! + #expect(mockDelegate.verifyCount(.throwException) == 0) + + mockDelegate.remoteLoadStatus = .NewUpdateLoaded + errorRecovery.notify(newRemoteLoadStatus: .NewUpdateLoaded) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.relaunch) == 0) + #expect(mockDelegate.verifyCount(.throwException) == 1) + } + + @Test + func `NewBrokenUpdateLoaded_WorkingUpdateCached`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .NewUpdateLoaded + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 1) + #expect(mockDelegate.verifyCount(.relaunch) == 1) + + let error2 = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error2) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 2) + #expect(mockDelegate.verifyCount(.relaunch) == 2) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + #expect(mockDelegate.verifyCount(.throwException) == 0) + } + + @Test + func `NewBrokenUpdateLoaded_UpdateAlreadyLaunchedSuccessfully`() throws { + let (testQueue, errorRecovery) = setUp() + // if an update has already been launched successfully, we don't want to fall back to an older update + + let config = try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]) + let database = UpdatesDatabase() + let mockDelegate = MockErrorRecoveryDelegate( + config: config, + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .NewUpdateLoaded + + let mockUpdate = Update( + manifest: ManifestFactory.manifest(forManifestJSON: [:]), + config: config, + database: database, + updateId: UUID(), + scopeKey: "wat", + commitTime: Date(), + runtimeVersion: "1.0", + keep: true, + status: .Status0_Unused, + isDevelopmentMode: false, + assetsFromManifest: [], + url: URL(string: "https://example.com"), + requestHeaders: [:] + ) + mockUpdate.successfulLaunchCount = 1 + + mockDelegate.launchedUpdateToReturn = mockUpdate + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 0) + #expect(mockDelegate.verifyCount(.relaunch) == 1) + + mockUpdate.successfulLaunchCount = 0 + let error2 = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error2) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 1) + #expect(mockDelegate.verifyCount(.throwException) == 1) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + } + + @Test(.disabled("flaky on CI")) + func `RemoteLoadTimesOut`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Loading + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + + // wait for more than 500ms + Thread.sleep(forTimeInterval: 0.6) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 1) + #expect(mockDelegate.verifyCount(.relaunch) == 1) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + #expect(mockDelegate.verifyCount(.throwException) == 0) + } + + @Test(.disabled("flaky on CI")) + func `RemoteLoadTimesOut_UpdateAlreadyLaunchedSuccessfully`() throws { + let (testQueue, errorRecovery) = setUp() + // if an update has already been launched successfully, we don't want to fall back to an older update + let config = try UpdatesConfig.config(fromDictionary: [:]) + let database = UpdatesDatabase() + let mockDelegate = MockErrorRecoveryDelegate( + config: config, + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Loading + errorRecovery.delegate = mockDelegate + + let mockUpdate = Update( + manifest: ManifestFactory.manifest(forManifestJSON: [:]), + config: config, + database: database, + updateId: UUID(), + scopeKey: "wat", + commitTime: Date(), + runtimeVersion: "1.0", + keep: true, + status: .Status0_Unused, + isDevelopmentMode: false, + assetsFromManifest: [], + url: nil, + requestHeaders: [:] + ) + mockUpdate.successfulLaunchCount = 1 + + mockDelegate.launchedUpdateToReturn = mockUpdate + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + + // wait for more than 500ms + Thread.sleep(forTimeInterval: 0.6) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.throwException) == 1) + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 0) + #expect(mockDelegate.verifyCount(.relaunch) == 0) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + } + + @Test(.disabled("flaky on CI")) + func `RemoteLoadTimesOut_RCTContentDidAppear`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Loading + errorRecovery.delegate = mockDelegate + + errorRecovery.startMonitoring() + NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) + #expect(mockDelegate.verifyCount(.markSuccessfulLaunchForLaunchedUpdate) == 1) + + // if RCTContentDidAppear has already fired, we don't want to roll back to an older update + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + + // wait for more than 500ms + Thread.sleep(forTimeInterval: 0.6) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.throwException) == 1) + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 0) + #expect(mockDelegate.verifyCount(.relaunch) == 0) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + } + + @Test + func `NoRemoteUpdate`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Idle + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 1) + // should try to load a remote update since we don't have one already + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 1) + + // indicate there isn't a new update from the server + errorRecovery.notify(newRemoteLoadStatus: .Idle) + testQueue.flush() + #expect(mockDelegate.verifyCount(.relaunch) == 1) + } + + @Test + func `NoRemoteUpdate_RCTContentDidAppear`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Idle + errorRecovery.delegate = mockDelegate + + errorRecovery.startMonitoring() + NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) + #expect(mockDelegate.verifyCount(.markSuccessfulLaunchForLaunchedUpdate) == 1) + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + testQueue.flush() + + // should try to load a remote update since we don't have one already + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 1) + + // indicate there isn't a new update from the server + errorRecovery.notify(newRemoteLoadStatus: .Idle) + testQueue.flush() + #expect(mockDelegate.verifyCount(.throwException) == 1) + } + + @Test + func `CheckAutomaticallyNever`() throws { + let (testQueue, errorRecovery) = setUp() + let config = try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigCheckOnLaunchKey: UpdatesConfig.EXUpdatesConfigCheckOnLaunchValueNever, + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]) + let mockDelegate = MockErrorRecoveryDelegate( + config: config, + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Idle + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 1) + #expect(mockDelegate.verifyCount(.relaunch) == 1) + } + + @Test + func `CheckAutomaticallyNever_RCTContentDidAppear`() throws { + let (testQueue, errorRecovery) = setUp() + let config = try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigCheckOnLaunchKey: UpdatesConfig.EXUpdatesConfigCheckOnLaunchValueNever, + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]) + let mockDelegate = MockErrorRecoveryDelegate( + config: config, + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Idle + errorRecovery.delegate = mockDelegate + + errorRecovery.startMonitoring() + NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil) + #expect(mockDelegate.verifyCount(.markSuccessfulLaunchForLaunchedUpdate) == 1) + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.throwException) == 1) + } + + // MARK: - multiple errors + + @Test + func `handles two errors`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .Idle + errorRecovery.delegate = mockDelegate + + let error = NSError(domain: "wat", code: 1) + errorRecovery.handle(error: error) + errorRecovery.handle(error: error) + testQueue.flush() + + // the actual error recovery should only happen once despite there being two errors + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 1) + } + + // MARK: - exceptions + + @Test + func `handles exceptions`() throws { + let (testQueue, errorRecovery) = setUp() + let mockDelegate = MockErrorRecoveryDelegate( + config: try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]), + relaunchCompletionParams: (nil, true) + ) + mockDelegate.remoteLoadStatus = .NewUpdateLoaded + errorRecovery.delegate = mockDelegate + + let testException = NSException(name: NSExceptionName.genericException, reason: "wat") + errorRecovery.handle(exception: testException) + testQueue.flush() + + #expect(mockDelegate.verifyCount(.markFailedLaunchForLaunchedUpdate) == 1) + #expect(mockDelegate.verifyCount(.relaunch) == 1) + #expect(mockDelegate.verifyCount(.loadRemoteUpdate) == 0) + #expect(mockDelegate.verifyCount(.throwException) == 0) + } + + // MARK: - error log + + @Test + func `error log consume`() { + let logger = UpdatesLogger() + let (testQueue, _) = setUp() + // start with a clean slate + _ = ErrorRecovery.consumeErrorLog(logger: logger) + + let error = NSError(domain: "TestDomain", code: 47, userInfo: [NSLocalizedDescriptionKey: "TestLocalizedDescription"]) + ErrorRecovery.writeErrorOrExceptionToLog(error, logger, dispatchQueue: testQueue) + testQueue.flush() + DispatchQueue.global().sync {} + + let errorLog = ErrorRecovery.consumeErrorLog(logger: logger) + #expect(errorLog?.contains("TestDomain") == true) + #expect(errorLog?.contains("47") == true) + #expect(errorLog?.contains("TestLocalizedDescription") == true) + } + + @Test + func `error log consume multiple errors`() { + let logger = UpdatesLogger() + let (testQueue, _) = setUp() + // start with a clean slate + _ = ErrorRecovery.consumeErrorLog(logger: logger) + + let error = NSError(domain: "TestDomain", code: 47, userInfo: [NSLocalizedDescriptionKey: "TestLocalizedDescription"]) + ErrorRecovery.writeErrorOrExceptionToLog(error, logger, dispatchQueue: testQueue) + + let exception = NSException(name: NSExceptionName(rawValue: "TestName"), reason: "TestReason") + ErrorRecovery.writeErrorOrExceptionToLog(exception, logger, dispatchQueue: testQueue) + testQueue.flush() + DispatchQueue.global().sync {} + + let errorLog = ErrorRecovery.consumeErrorLog(logger: logger) + #expect(errorLog?.contains("TestDomain") == true) + #expect(errorLog?.contains("47") == true) + #expect(errorLog?.contains("TestLocalizedDescription") == true) + #expect(errorLog?.contains("TestName") == true) + #expect(errorLog?.contains("TestReason") == true) + } +} diff --git a/packages/expo-updates/ios/Tests/FileDownloaderTests.swift b/packages/expo-updates/ios/Tests/FileDownloaderTests.swift index 2e401ccbbd4d79..3f7ff3549d42c8 100644 --- a/packages/expo-updates/ios/Tests/FileDownloaderTests.swift +++ b/packages/expo-updates/ios/Tests/FileDownloaderTests.swift @@ -98,6 +98,7 @@ private class MockURLProtocol: URLProtocol { } @Suite("FileDownloader", .serialized) +@MainActor class FileDownloaderTests { var testDatabaseDir: URL var testUpdatesDir: URL diff --git a/packages/expo-updates/ios/Tests/HermesDiffSpec.swift b/packages/expo-updates/ios/Tests/HermesDiffSpec.swift deleted file mode 100644 index 800c7a43cc6001..00000000000000 --- a/packages/expo-updates/ios/Tests/HermesDiffSpec.swift +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2024 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore -import Foundation - -@testable import EXUpdates - -class HermesDiffSpecForBundle {} - -class HermesDiffSpec : ExpoSpec { - override class func spec() { - describe("Hermes bytecode diffing") { - it("should successfully apply diff using real bytecode files") { - let bundle = Bundle(for: HermesDiffSpecForBundle.self) - - let oldHbcPath = bundle.path(forResource: "old", ofType: "hbc")! - let expectedHbcPath = bundle.path(forResource: "new", ofType: "hbc")! - let patchPath = bundle.path(forResource: "test", ofType: "patch")! - - let tempDir = FileManager.default.temporaryDirectory - let resultHbcPath = tempDir.appendingPathComponent("result.hbc").path - - try BSPatch.applyPatch( - oldPath: oldHbcPath, - newPath: resultHbcPath, - patchPath: patchPath - ) - - let expectedData = try Data(contentsOf: URL(fileURLWithPath: expectedHbcPath)) - let resultData = try Data(contentsOf: URL(fileURLWithPath: resultHbcPath)) - - expect(resultData).to(equal(expectedData)) - - try? FileManager.default.removeItem(atPath: resultHbcPath) - } - - it("should handle bad patch") { - let tempDir = FileManager.default.temporaryDirectory - let oldHbcUrl = tempDir.appendingPathComponent("old_corrupt.hbc") - let resultHbcUrl = tempDir.appendingPathComponent("result_corrupt.hbc") - let corruptPatchUrl = tempDir.appendingPathComponent("corrupt.patch") - - let dummyData = Data("dummy hermes bytecode".utf8) - try dummyData.write(to: oldHbcUrl) - - // Create bad patch (random data) - let corruptPatchData = Data((0..<1024).map { _ in UInt8.random(in: 0...255) }) - try corruptPatchData.write(to: corruptPatchUrl) - - expect { - try BSPatch.applyPatch( - oldPath: oldHbcUrl.path, - newPath: resultHbcUrl.path, - patchPath: corruptPatchUrl.path - ) - }.to(throwError { (error: BSPatchError) in - if case .failed(let message) = error { - expect(message).toNot(beEmpty()) - } - }) - - try? FileManager.default.removeItem(at: oldHbcUrl) - try? FileManager.default.removeItem(at: resultHbcUrl) - try? FileManager.default.removeItem(at: corruptPatchUrl) - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/HermesDiffTests.swift b/packages/expo-updates/ios/Tests/HermesDiffTests.swift new file mode 100644 index 00000000000000..49feeb8e185e3b --- /dev/null +++ b/packages/expo-updates/ios/Tests/HermesDiffTests.swift @@ -0,0 +1,69 @@ +// Copyright (c) 2024 650 Industries, Inc. All rights reserved. + +import Testing +import Foundation + +@testable import EXUpdates + +class HermesDiffTestsForBundle {} + +@Suite("Hermes bytecode diffing") +struct HermesDiffTests { + @Test + func `should successfully apply diff using real bytecode files`() throws { + let bundle = Bundle(for: HermesDiffTestsForBundle.self) + + let oldHbcPath = bundle.path(forResource: "old", ofType: "hbc")! + let expectedHbcPath = bundle.path(forResource: "new", ofType: "hbc")! + let patchPath = bundle.path(forResource: "test", ofType: "patch")! + + let tempDir = FileManager.default.temporaryDirectory + let resultHbcPath = tempDir.appendingPathComponent("result.hbc").path + + try BSPatch.applyPatch( + oldPath: oldHbcPath, + newPath: resultHbcPath, + patchPath: patchPath + ) + + let expectedData = try Data(contentsOf: URL(fileURLWithPath: expectedHbcPath)) + let resultData = try Data(contentsOf: URL(fileURLWithPath: resultHbcPath)) + + #expect(resultData == expectedData) + + try? FileManager.default.removeItem(atPath: resultHbcPath) + } + + @Test + func `should handle bad patch`() throws { + let tempDir = FileManager.default.temporaryDirectory + let oldHbcUrl = tempDir.appendingPathComponent("old_corrupt.hbc") + let resultHbcUrl = tempDir.appendingPathComponent("result_corrupt.hbc") + let corruptPatchUrl = tempDir.appendingPathComponent("corrupt.patch") + + let dummyData = Data("dummy hermes bytecode".utf8) + try dummyData.write(to: oldHbcUrl) + + // Create bad patch (random data) + let corruptPatchData = Data((0..<1024).map { _ in UInt8.random(in: 0...255) }) + try corruptPatchData.write(to: corruptPatchUrl) + + #expect { + try BSPatch.applyPatch( + oldPath: oldHbcUrl.path, + newPath: resultHbcUrl.path, + patchPath: corruptPatchUrl.path + ) + } throws: { error in + guard let bspatchError = error as? BSPatchError, + case .failed(let message) = bspatchError else { + return false + } + return !message.isEmpty + } + + try? FileManager.default.removeItem(at: oldHbcUrl) + try? FileManager.default.removeItem(at: resultHbcUrl) + try? FileManager.default.removeItem(at: corruptPatchUrl) + } +} diff --git a/packages/expo-updates/ios/Tests/NewUpdateSpec.swift b/packages/expo-updates/ios/Tests/NewUpdateSpec.swift deleted file mode 100644 index 2804cac0c97e9b..00000000000000 --- a/packages/expo-updates/ios/Tests/NewUpdateSpec.swift +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -import EXManifests - -class NewUpdateSpec : ExpoSpec { - override class func spec() { - let config = try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://u.expo.dev/00000000-0000-0000-0000-000000000000", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - ]) - let database = UpdatesDatabase() - - describe("instantiation") { - it("all fields") { - let manifest = ExpoUpdatesManifest( - rawManifestJSON: [ - "runtimeVersion": "1", - "id": "0eef8214-4833-4089-9dff-b4138a14f196", - "createdAt": "2020-11-11T00:17:54.797Z", - "launchAsset": [ - "url": "https://url.to/bundle.js", - "contentType": "application/javascript" - ] - ] - ) - - expect(ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: manifest, - extensions: [:], - config: config, - database: database - )).notTo(beNil()) - } - - it("no runtime version") { - let manifest = ExpoUpdatesManifest( - rawManifestJSON: [ - "id": "0eef8214-4833-4089-9dff-b4138a14f196", - "createdAt": "2020-11-11T00:17:54.797Z", - "launchAsset": [ - "url": "https://url.to/bundle.js", - "contentType": "application/javascript" - ] - ] - ) - - expect(ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: manifest, - extensions: [:], - config: config, - database: database - )).to(raiseException()) - } - - it("no id") { - let manifest = ExpoUpdatesManifest( - rawManifestJSON: [ - "runtimeVersion": "1", - "createdAt": "2020-11-11T00:17:54.797Z", - "launchAsset": [ - "url": "https://url.to/bundle.js", - "contentType": "application/javascript" - ] - ] - ) - - expect(ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: manifest, - extensions: [:], - config: config, - database: database - )).to(raiseException()) - } - - it("no created at") { - let manifest = ExpoUpdatesManifest( - rawManifestJSON: [ - "runtimeVersion": "1", - "id": "0eef8214-4833-4089-9dff-b4138a14f196", - "launchAsset": [ - "url": "https://url.to/bundle.js", - "contentType": "application/javascript" - ] - ] - ) - - expect(ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: manifest, - extensions: [:], - config: config, - database: database - )).to(raiseException()) - } - - it("no launch asset") { - let manifest = ExpoUpdatesManifest( - rawManifestJSON: [ - "runtimeVersion": "1", - "id": "0eef8214-4833-4089-9dff-b4138a14f196", - "createdAt": "2020-11-11T00:17:54.797Z", - ] - ) - - expect(ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: manifest, - extensions: [:], - config: config, - database: database - )).to(raiseException()) - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/NewUpdateTests.swift b/packages/expo-updates/ios/Tests/NewUpdateTests.swift new file mode 100644 index 00000000000000..e31db393bc0775 --- /dev/null +++ b/packages/expo-updates/ios/Tests/NewUpdateTests.swift @@ -0,0 +1,138 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing +import ExpoModulesCore + +@testable import EXUpdates + +import EXManifests + +@Suite("ExpoUpdatesUpdate instantiation") +struct NewUpdateTests { + let config = try! UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://u.expo.dev/00000000-0000-0000-0000-000000000000", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]) + let database = UpdatesDatabase() + + @Test + func `all fields`() { + let manifest = ExpoUpdatesManifest( + rawManifestJSON: [ + "runtimeVersion": "1", + "id": "0eef8214-4833-4089-9dff-b4138a14f196", + "createdAt": "2020-11-11T00:17:54.797Z", + "launchAsset": [ + "url": "https://url.to/bundle.js", + "contentType": "application/javascript" + ] + ] + ) + + #expect(throws: Never.self) { + _ = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: manifest, + extensions: [:], + config: config, + database: database + ) + } + } + + @Test + func `throws no runtime version`() { + let manifest = ExpoUpdatesManifest( + rawManifestJSON: [ + "id": "0eef8214-4833-4089-9dff-b4138a14f196", + "createdAt": "2020-11-11T00:17:54.797Z", + "launchAsset": [ + "url": "https://url.to/bundle.js", + "contentType": "application/javascript" + ] + ] + ) + + #expect(throws: (any Error).self) { + try EXUtilities.catchException { + _ = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: manifest, + extensions: [:], + config: self.config, + database: self.database + ) + } + } + } + + @Test + func `throws when no id`() { + let manifest = ExpoUpdatesManifest( + rawManifestJSON: [ + "runtimeVersion": "1", + "createdAt": "2020-11-11T00:17:54.797Z", + "launchAsset": [ + "url": "https://url.to/bundle.js", + "contentType": "application/javascript" + ] + ] + ) + + #expect(throws: (any Error).self) { + try EXUtilities.catchException { + _ = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: manifest, + extensions: [:], + config: self.config, + database: self.database + ) + } + } + } + + @Test + func `throws no created at`() { + let manifest = ExpoUpdatesManifest( + rawManifestJSON: [ + "runtimeVersion": "1", + "id": "0eef8214-4833-4089-9dff-b4138a14f196", + "launchAsset": [ + "url": "https://url.to/bundle.js", + "contentType": "application/javascript" + ] + ] + ) + + #expect(throws: (any Error).self) { + try EXUtilities.catchException { + _ = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: manifest, + extensions: [:], + config: self.config, + database: self.database + ) + } + } + } + + @Test + func `throws when no launch asset`() { + let manifest = ExpoUpdatesManifest( + rawManifestJSON: [ + "runtimeVersion": "1", + "id": "0eef8214-4833-4089-9dff-b4138a14f196", + "createdAt": "2020-11-11T00:17:54.797Z", + ] + ) + + #expect(throws: (any Error).self) { + try EXUtilities.catchException { + _ = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: manifest, + extensions: [:], + config: self.config, + database: self.database + ) + } + } + } +} diff --git a/packages/expo-updates/ios/Tests/ResponseHeaderDataSpec.swift b/packages/expo-updates/ios/Tests/ResponseHeaderDataSpec.swift deleted file mode 100644 index 20d0949c77366a..00000000000000 --- a/packages/expo-updates/ios/Tests/ResponseHeaderDataSpec.swift +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -class ResponseHeaderDataSpec : ExpoSpec { - override class func spec() { - describe("dictionaryWithStructuredHeader") { - it("SupportedTypes") { - let header = "string=\"string-0000\", true=?1, false=?0, integer=47, decimal=47.5" - let expected: [String : Any] = [ - "string": "string-0000", - "true": true, - "false": false, - "integer": 47, - "decimal": 47.5 - ] - let actual = ResponseHeaderData.dictionaryWithStructuredHeader(header) - expect(NSDictionary(dictionary: expected).isEqual(to: actual!)).to(beTrue()) - } - - it("IgnoresOtherTypes") { - let header = "branch-name=\"rollout-1\", data=:w4ZibGV0w6ZydGUK:, list=(1 2)" - let expected: [String : Any] = [ - "branch-name": "rollout-1" - ] - let actual = ResponseHeaderData.dictionaryWithStructuredHeader(header) - expect(NSDictionary(dictionary: expected).isEqual(to: actual!)).to(beTrue()) - } - - it("IgnoresParameters") { - let header = "abc=123;a=1;b=2" - let expected: [String : Any] = [ - "abc": 123 - ] - let actual = ResponseHeaderData.dictionaryWithStructuredHeader(header) - expect(NSDictionary(dictionary: expected).isEqual(to: actual!)).to(beTrue()) - } - - it("Empty") { - let header = "" - let expected: [String : Any] = [:] - let actual = ResponseHeaderData.dictionaryWithStructuredHeader(header) - expect(NSDictionary(dictionary: expected).isEqual(to: actual!)).to(beTrue()) - } - - it("ParsingError") { - let header = "bad dictionary" - expect(ResponseHeaderData.dictionaryWithStructuredHeader(header)).to(beNil()) - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/ResponseHeaderDataTests.swift b/packages/expo-updates/ios/Tests/ResponseHeaderDataTests.swift new file mode 100644 index 00000000000000..63d1ac6bf7d61a --- /dev/null +++ b/packages/expo-updates/ios/Tests/ResponseHeaderDataTests.swift @@ -0,0 +1,56 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +@Suite("ResponseHeaderData.dictionaryWithStructuredHeader") +struct ResponseHeaderDataTests { + @Test + func `SupportedTypes`() { + let header = "string=\"string-0000\", true=?1, false=?0, integer=47, decimal=47.5" + let expected: [String: Any] = [ + "string": "string-0000", + "true": true, + "false": false, + "integer": 47, + "decimal": 47.5 + ] + let actual = ResponseHeaderData.dictionaryWithStructuredHeader(header) + #expect(NSDictionary(dictionary: expected).isEqual(to: actual!)) + } + + @Test + func `IgnoresOtherTypes`() { + let header = "branch-name=\"rollout-1\", data=:w4ZibGV0w6ZydGUK:, list=(1 2)" + let expected: [String: Any] = [ + "branch-name": "rollout-1" + ] + let actual = ResponseHeaderData.dictionaryWithStructuredHeader(header) + #expect(NSDictionary(dictionary: expected).isEqual(to: actual!)) + } + + @Test + func `IgnoresParameters`() { + let header = "abc=123;a=1;b=2" + let expected: [String: Any] = [ + "abc": 123 + ] + let actual = ResponseHeaderData.dictionaryWithStructuredHeader(header) + #expect(NSDictionary(dictionary: expected).isEqual(to: actual!)) + } + + @Test + func `Empty`() { + let header = "" + let expected: [String: Any] = [:] + let actual = ResponseHeaderData.dictionaryWithStructuredHeader(header) + #expect(NSDictionary(dictionary: expected).isEqual(to: actual!)) + } + + @Test + func `ParsingError`() { + let header = "bad dictionary" + #expect(ResponseHeaderData.dictionaryWithStructuredHeader(header) == nil) + } +} diff --git a/packages/expo-updates/ios/Tests/SelectionPolicyFilterAwareSpec.swift b/packages/expo-updates/ios/Tests/SelectionPolicyFilterAwareSpec.swift deleted file mode 100644 index 20076f156767b8..00000000000000 --- a/packages/expo-updates/ios/Tests/SelectionPolicyFilterAwareSpec.swift +++ /dev/null @@ -1,274 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -import EXManifests - -class SelectionPolicyFilterAwareSpec : ExpoSpec { - override class func spec() { - var updateDefault1: Update! - var updateDefault2: Update! - var updateRollout0: Update! - var updateRollout1: Update! - var updateRollout2: Update! - var updateMultipleFilters: Update! - var updateNoMetadata: Update! - - var selectionPolicy: SelectionPolicy! - var manifestFilters: [String: Any]! - - beforeEach { - let launchAsset = [ - "hash": "DW5MBgKq155wnX8rCP1lnsW6BsTbfKLXxGXRQx1RcOA", - "key": "0436e5821bff7b95a84c21f22a43cb96.bundle", - "contentType": "application/javascript", - "fileExtension": ".js", - "url": "https://url.to/bundle" - ] - - let imageAsset = [ - "hash": "JSeRsPNKzhVdHP1OEsDVsLH500Zfe4j1O7xWfa14oBo", - "key": "3261e570d51777be1e99116562280926.png", - "contentType": "image/png", - "fileExtension": ".png", - "url": "https://url.to/asset" - ] - - let runtimeVersion = "1.0" - let scopeKey = "dummyScope" - let config = try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: runtimeVersion, - UpdatesConfig.EXUpdatesConfigScopeKeyKey: scopeKey - ]) - let database = UpdatesDatabase() - - updateRollout0 = ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ - "id": "079cde35-8433-4c17-81c8-7117c1513e71", - "createdAt": "2021-01-10T19:39:22.480Z", - "runtimeVersion": "1.0", - "launchAsset": launchAsset, - "assets": [imageAsset], - "metadata": ["branchName": "rollout"] - ]), - extensions: [:], - config: config, - database: database - ) - - updateDefault1 = ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ - "id": "079cde35-8433-4c17-81c8-7117c1513e72", - "createdAt": "2021-01-11T19:39:22.480Z", - "runtimeVersion": "1.0", - "launchAsset": launchAsset, - "assets": [imageAsset], - "metadata": ["branchName": "default"] - ]), - extensions: [:], - config: config, - database: database - ) - - updateRollout1 = ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ - "id": "079cde35-8433-4c17-81c8-7117c1513e73", - "createdAt": "2021-01-12T19:39:22.480Z", - "runtimeVersion": "1.0", - "launchAsset": launchAsset, - "assets": [imageAsset], - "metadata": ["branchName": "rollout"] - ]), - extensions: [:], - config: config, - database: database - ) - - updateDefault2 = ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ - "id": "079cde35-8433-4c17-81c8-7117c1513e74", - "createdAt": "2021-01-13T19:39:22.480Z", - "runtimeVersion": "1.0", - "launchAsset": launchAsset, - "assets": [imageAsset], - "metadata": ["branchName": "default"] - ]), - extensions: [:], - config: config, - database: database - ) - - updateRollout2 = ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ - "id": "079cde35-8433-4c17-81c8-7117c1513e75", - "createdAt": "2021-01-14T19:39:22.480Z", - "runtimeVersion": "1.0", - "launchAsset": launchAsset, - "assets": [imageAsset], - "metadata": ["branchName": "rollout"] - ]), - extensions: [:], - config: config, - database: database - ) - - updateMultipleFilters = ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ - "id": "079cde35-8433-4c17-81c8-7117c1513e72", - "createdAt": "2021-01-11T19:39:22.480Z", - "runtimeVersion": "1.0", - "launchAsset": launchAsset, - "assets": [imageAsset], - "metadata": ["firstKey": "value1", "secondKey": "value2"] - ]), - extensions: [:], - config: config, - database: database - ) - - updateNoMetadata = ExpoUpdatesUpdate.update( - withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ - "id": "079cde35-8433-4c17-81c8-7117c1513e72", - "createdAt": "2021-01-11T19:39:22.480Z", - "runtimeVersion": "1.0", - "launchAsset": launchAsset, - "assets": [imageAsset] - ]), - extensions: [:], - config: config, - database: database - ) - - selectionPolicy = SelectionPolicyFactory.filterAwarePolicy(withRuntimeVersion: runtimeVersion, config: config) - manifestFilters = ["branchname": "rollout"] - } - - describe("filtering") { - it("launchable updates") { - let actual = selectionPolicy.launchableUpdate(fromUpdates: [updateDefault1, updateRollout1, updateDefault2], filters: manifestFilters) - expect(actual) == updateRollout1 - } - - it("delete - second newest matching") { - let actual = selectionPolicy.updatesToDelete(withLaunchedUpdate: updateRollout2, updates: [updateRollout0, updateDefault1, updateRollout1, updateDefault2, updateRollout2], filters: manifestFilters) - expect(actual.count) == 3 - - expect(actual.contains(updateDefault1)) == true - expect(actual.contains(updateDefault2)) == true - expect(actual.contains(updateRollout0)) == true - expect(actual.contains(updateRollout1)) == false - expect(actual.contains(updateRollout2)) == false - } - - it("delete - none older matching") { - let actual = selectionPolicy.updatesToDelete(withLaunchedUpdate: updateRollout2, updates: [updateDefault1, updateDefault2, updateRollout2], filters: manifestFilters) - expect(actual.count) == 1 - - expect(actual.contains(updateDefault1)) == true - expect(actual.contains(updateDefault2)) == false - expect(actual.contains(updateRollout2)) == false - } - - it("should load new update - normal case - new update") { - expect(selectionPolicy.shouldLoadNewUpdate(updateRollout2, withLaunchedUpdate: updateRollout1, filters: manifestFilters)) == true - } - - it("should load new update - normal case - no update") { - expect(selectionPolicy.shouldLoadNewUpdate(updateRollout1, withLaunchedUpdate: updateRollout1, filters: manifestFilters)) == false - } - - it("should load new update - normal case - older update") { - // this could happen if the embedded update is newer than the most recently published update - expect(selectionPolicy.shouldLoadNewUpdate(updateRollout1, withLaunchedUpdate: updateRollout2, filters: manifestFilters)) == false - } - - it("should load new update - none matching filters") { - expect(selectionPolicy.shouldLoadNewUpdate(updateRollout1, withLaunchedUpdate: updateDefault2, filters: manifestFilters)) == true - } - - it("should load new update - newer exists") { - expect(selectionPolicy.shouldLoadNewUpdate(updateRollout1, withLaunchedUpdate: updateRollout2, filters: manifestFilters)) == false - } - - it("should load new update - doesnt match") { - expect(selectionPolicy.shouldLoadNewUpdate(updateDefault2, withLaunchedUpdate: nil, filters: manifestFilters)) == false - } - - it("should load rollback to embedded directive - embedded does not match filters") { - expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( - RollBackToEmbeddedUpdateDirective(commitTime: Date(), signingInfo: nil), - withEmbeddedUpdate: updateDefault1, - launchedUpdate: nil, - filters: manifestFilters - )) == false - } - - it("should load rollback to embedded directive - no launched update") { - expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( - RollBackToEmbeddedUpdateDirective(commitTime: Date(), signingInfo: nil), - withEmbeddedUpdate: updateRollout0, - launchedUpdate: nil, - filters: manifestFilters - )) == true - } - - it("should load rollback to embedded directive - launched update does not match filters") { - expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( - RollBackToEmbeddedUpdateDirective(commitTime: Date(), signingInfo: nil), - withEmbeddedUpdate: updateRollout0, - launchedUpdate: updateDefault1, - filters: manifestFilters - )) == true - } - - it("should load rollback to embedded directive - commit time of launched update before roll back") { - // updateRollout1 has commitTime = 2021-01-12T19:39:22.480Z - // roll back is 1 year later - expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( - RollBackToEmbeddedUpdateDirective(commitTime: RCTConvert.nsDate("2022-01-12T19:39:22.480Z"), signingInfo: nil), - withEmbeddedUpdate: updateRollout0, - launchedUpdate: updateRollout1, - filters: manifestFilters - )) == true - } - - it("should load rollback to embedded directive - commit time of launched update before roll back") { - // updateRollout1 has commitTime = 2021-01-12T19:39:22.480Z - // roll back is 1 year earlier - expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( - RollBackToEmbeddedUpdateDirective(commitTime: RCTConvert.nsDate("2020-01-12T19:39:22.480Z"), signingInfo: nil), - withEmbeddedUpdate: updateRollout0, - launchedUpdate: updateRollout1, - filters: manifestFilters - )) == false - } - - it("does update match filters - multiple filters") { - let filtersBadMatch = [ - "firstkey": "value1", - "secondkey": "wrong-value" - ] - expect(SelectionPolicies.doesUpdate(updateMultipleFilters, matchFilters: filtersBadMatch)) == false - - let filtersGoodMatch = [ - "firstkey": "value1", - "secondkey": "value2" - ] - expect(SelectionPolicies.doesUpdate(updateMultipleFilters, matchFilters: filtersGoodMatch)) == true - } - - it("does update match filters - empty matches all") { - expect(SelectionPolicies.doesUpdate(updateDefault1, matchFilters: ["field-that-update-doesnt-have": "value"])) == true - } - - it("does update match filters - null") { - // null filters or null metadata (i.e. bare or legacy manifests) is counted as a match - expect(SelectionPolicies.doesUpdate(updateDefault1, matchFilters: nil)) == true - expect(SelectionPolicies.doesUpdate(updateNoMetadata, matchFilters: manifestFilters)) == true - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/SelectionPolicyFilterAwareTests.swift b/packages/expo-updates/ios/Tests/SelectionPolicyFilterAwareTests.swift new file mode 100644 index 00000000000000..4fe53315a6ff0d --- /dev/null +++ b/packages/expo-updates/ios/Tests/SelectionPolicyFilterAwareTests.swift @@ -0,0 +1,290 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing +import React + +@testable import EXUpdates + +import EXManifests + +@Suite("SelectionPolicyFilterAware") +struct SelectionPolicyFilterAwareTests { + var updateDefault1: Update + var updateDefault2: Update + var updateRollout0: Update + var updateRollout1: Update + var updateRollout2: Update + var updateMultipleFilters: Update + var updateNoMetadata: Update + var selectionPolicy: SelectionPolicy + var manifestFilters: [String: Any] + + init() throws { + let launchAsset: [String: Any] = [ + "hash": "DW5MBgKq155wnX8rCP1lnsW6BsTbfKLXxGXRQx1RcOA", + "key": "0436e5821bff7b95a84c21f22a43cb96.bundle", + "contentType": "application/javascript", + "fileExtension": ".js", + "url": "https://url.to/bundle" + ] + + let imageAsset: [String: Any] = [ + "hash": "JSeRsPNKzhVdHP1OEsDVsLH500Zfe4j1O7xWfa14oBo", + "key": "3261e570d51777be1e99116562280926.png", + "contentType": "image/png", + "fileExtension": ".png", + "url": "https://url.to/asset" + ] + + let runtimeVersion = "1.0" + let scopeKey = "dummyScope" + let config = try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: runtimeVersion, + UpdatesConfig.EXUpdatesConfigScopeKeyKey: scopeKey + ]) + let database = UpdatesDatabase() + + updateRollout0 = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ + "id": "079cde35-8433-4c17-81c8-7117c1513e71", + "createdAt": "2021-01-10T19:39:22.480Z", + "runtimeVersion": "1.0", + "launchAsset": launchAsset, + "assets": [imageAsset], + "metadata": ["branchName": "rollout"] + ]), + extensions: [:], + config: config, + database: database + ) + + updateDefault1 = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ + "id": "079cde35-8433-4c17-81c8-7117c1513e72", + "createdAt": "2021-01-11T19:39:22.480Z", + "runtimeVersion": "1.0", + "launchAsset": launchAsset, + "assets": [imageAsset], + "metadata": ["branchName": "default"] + ]), + extensions: [:], + config: config, + database: database + ) + + updateRollout1 = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ + "id": "079cde35-8433-4c17-81c8-7117c1513e73", + "createdAt": "2021-01-12T19:39:22.480Z", + "runtimeVersion": "1.0", + "launchAsset": launchAsset, + "assets": [imageAsset], + "metadata": ["branchName": "rollout"] + ]), + extensions: [:], + config: config, + database: database + ) + + updateDefault2 = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ + "id": "079cde35-8433-4c17-81c8-7117c1513e74", + "createdAt": "2021-01-13T19:39:22.480Z", + "runtimeVersion": "1.0", + "launchAsset": launchAsset, + "assets": [imageAsset], + "metadata": ["branchName": "default"] + ]), + extensions: [:], + config: config, + database: database + ) + + updateRollout2 = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ + "id": "079cde35-8433-4c17-81c8-7117c1513e75", + "createdAt": "2021-01-14T19:39:22.480Z", + "runtimeVersion": "1.0", + "launchAsset": launchAsset, + "assets": [imageAsset], + "metadata": ["branchName": "rollout"] + ]), + extensions: [:], + config: config, + database: database + ) + + updateMultipleFilters = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ + "id": "079cde35-8433-4c17-81c8-7117c1513e72", + "createdAt": "2021-01-11T19:39:22.480Z", + "runtimeVersion": "1.0", + "launchAsset": launchAsset, + "assets": [imageAsset], + "metadata": ["firstKey": "value1", "secondKey": "value2"] + ]), + extensions: [:], + config: config, + database: database + ) + + updateNoMetadata = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: [ + "id": "079cde35-8433-4c17-81c8-7117c1513e72", + "createdAt": "2021-01-11T19:39:22.480Z", + "runtimeVersion": "1.0", + "launchAsset": launchAsset, + "assets": [imageAsset] + ]), + extensions: [:], + config: config, + database: database + ) + + selectionPolicy = SelectionPolicyFactory.filterAwarePolicy(withRuntimeVersion: runtimeVersion, config: config) + manifestFilters = ["branchname": "rollout"] + } + + // MARK: - filtering + + @Test + func `launchable updates`() { + let actual = selectionPolicy.launchableUpdate(fromUpdates: [updateDefault1, updateRollout1, updateDefault2], filters: manifestFilters) + #expect(actual == updateRollout1) + } + + @Test + func `delete - second newest matching`() { + let actual = selectionPolicy.updatesToDelete(withLaunchedUpdate: updateRollout2, updates: [updateRollout0, updateDefault1, updateRollout1, updateDefault2, updateRollout2], filters: manifestFilters) + #expect(actual.count == 3) + + #expect(actual.contains(updateDefault1) == true) + #expect(actual.contains(updateDefault2) == true) + #expect(actual.contains(updateRollout0) == true) + #expect(actual.contains(updateRollout1) == false) + #expect(actual.contains(updateRollout2) == false) + } + + @Test + func `delete - none older matching`() { + let actual = selectionPolicy.updatesToDelete(withLaunchedUpdate: updateRollout2, updates: [updateDefault1, updateDefault2, updateRollout2], filters: manifestFilters) + #expect(actual.count == 1) + + #expect(actual.contains(updateDefault1) == true) + #expect(actual.contains(updateDefault2) == false) + #expect(actual.contains(updateRollout2) == false) + } + + @Test + func `should load new update - normal case - new update`() { + #expect(selectionPolicy.shouldLoadNewUpdate(updateRollout2, withLaunchedUpdate: updateRollout1, filters: manifestFilters) == true) + } + + @Test + func `should load new update - normal case - no update`() { + #expect(selectionPolicy.shouldLoadNewUpdate(updateRollout1, withLaunchedUpdate: updateRollout1, filters: manifestFilters) == false) + } + + @Test + func `should load new update - normal case - older update`() { + // this could happen if the embedded update is newer than the most recently published update + #expect(selectionPolicy.shouldLoadNewUpdate(updateRollout1, withLaunchedUpdate: updateRollout2, filters: manifestFilters) == false) + } + + @Test + func `should load new update - none matching filters`() { + #expect(selectionPolicy.shouldLoadNewUpdate(updateRollout1, withLaunchedUpdate: updateDefault2, filters: manifestFilters) == true) + } + + @Test + func `should load new update - newer exists`() { + #expect(selectionPolicy.shouldLoadNewUpdate(updateRollout1, withLaunchedUpdate: updateRollout2, filters: manifestFilters) == false) + } + + @Test + func `should load new update - doesnt match`() { + #expect(selectionPolicy.shouldLoadNewUpdate(updateDefault2, withLaunchedUpdate: nil, filters: manifestFilters) == false) + } + + @Test + func `should load rollback to embedded directive - embedded does not match filters`() { + #expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( + RollBackToEmbeddedUpdateDirective(commitTime: Date(), signingInfo: nil), + withEmbeddedUpdate: updateDefault1, + launchedUpdate: nil, + filters: manifestFilters + ) == false) + } + + @Test + func `should load rollback to embedded directive - no launched update`() { + #expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( + RollBackToEmbeddedUpdateDirective(commitTime: Date(), signingInfo: nil), + withEmbeddedUpdate: updateRollout0, + launchedUpdate: nil, + filters: manifestFilters + ) == true) + } + + @Test + func `should load rollback to embedded directive - launched update does not match filters`() { + #expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( + RollBackToEmbeddedUpdateDirective(commitTime: Date(), signingInfo: nil), + withEmbeddedUpdate: updateRollout0, + launchedUpdate: updateDefault1, + filters: manifestFilters + ) == true) + } + + @Test + func `should load rollback to embedded directive - commit time of launched update before roll back - true`() { + // updateRollout1 has commitTime = 2021-01-12T19:39:22.480Z + // roll back is 1 year later + #expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( + RollBackToEmbeddedUpdateDirective(commitTime: RCTConvert.nsDate("2022-01-12T19:39:22.480Z"), signingInfo: nil), + withEmbeddedUpdate: updateRollout0, + launchedUpdate: updateRollout1, + filters: manifestFilters + ) == true) + } + + @Test + func `should load rollback to embedded directive - commit time of launched update before roll back - false`() { + // updateRollout1 has commitTime = 2021-01-12T19:39:22.480Z + // roll back is 1 year earlier + #expect(selectionPolicy.shouldLoadRollBackToEmbeddedDirective( + RollBackToEmbeddedUpdateDirective(commitTime: RCTConvert.nsDate("2020-01-12T19:39:22.480Z"), signingInfo: nil), + withEmbeddedUpdate: updateRollout0, + launchedUpdate: updateRollout1, + filters: manifestFilters + ) == false) + } + + @Test + func `does update match filters - multiple filters`() { + let filtersBadMatch = [ + "firstkey": "value1", + "secondkey": "wrong-value" + ] + #expect(SelectionPolicies.doesUpdate(updateMultipleFilters, matchFilters: filtersBadMatch) == false) + + let filtersGoodMatch = [ + "firstkey": "value1", + "secondkey": "value2" + ] + #expect(SelectionPolicies.doesUpdate(updateMultipleFilters, matchFilters: filtersGoodMatch) == true) + } + + @Test + func `does update match filters - empty matches all`() { + #expect(SelectionPolicies.doesUpdate(updateDefault1, matchFilters: ["field-that-update-doesnt-have": "value"]) == true) + } + + @Test + func `does update match filters - null`() { + // null filters or null metadata (i.e. bare or legacy manifests) is counted as a match + #expect(SelectionPolicies.doesUpdate(updateDefault1, matchFilters: nil) == true) + #expect(SelectionPolicies.doesUpdate(updateNoMetadata, matchFilters: manifestFilters) == true) + } +} diff --git a/packages/expo-updates/ios/Tests/SignatureHeaderInfoSpec.swift b/packages/expo-updates/ios/Tests/SignatureHeaderInfoSpec.swift deleted file mode 100644 index be64d0d67572c8..00000000000000 --- a/packages/expo-updates/ios/Tests/SignatureHeaderInfoSpec.swift +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -class SignatureHeaderInfoSpec : ExpoSpec { - override class func spec() { - describe("parseSignatureHeader") { - it("ParsesCodeSigningInfo") { - let codeSigningInfo = try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "sig=\"12345\", keyid=\"test\", alg=\"rsa-v1_5-sha256\"") - expect(codeSigningInfo.signature) == "12345" - expect(codeSigningInfo.keyId) == "test" - expect(codeSigningInfo.algorithm) == CodeSigningAlgorithm.RSA_SHA256 - } - - it("DefaultsKeyIdAndAlg") { - let codeSigningInfo = try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "sig=\"12345\"") - expect(codeSigningInfo.signature) == "12345" - expect(codeSigningInfo.keyId) == "root" - expect(codeSigningInfo.algorithm) == CodeSigningAlgorithm.RSA_SHA256 - } - - it("ThrowsForInvalidAlg") { - expect { - try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "fake=\"12345\"") - }.to(throwError(CodeSigningError.SignatureHeaderSigMissing)) - - expect { - try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "fs=1") - }.to(throwError(CodeSigningError.SignatureHeaderStructuredFieldParseError)) - - expect { - try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "sig=\"12345\", alg=\"blah\"") - }.to(throwError(CodeSigningError.AlgorithmParseError)) - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/SignatureHeaderInfoTests.swift b/packages/expo-updates/ios/Tests/SignatureHeaderInfoTests.swift new file mode 100644 index 00000000000000..ce44d9b5e291ad --- /dev/null +++ b/packages/expo-updates/ios/Tests/SignatureHeaderInfoTests.swift @@ -0,0 +1,54 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +@Suite("SignatureHeaderInfo") +struct SignatureHeaderInfoTests { + @Test + func `ParsesCodeSigningInfo`() throws { + let codeSigningInfo = try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "sig=\"12345\", keyid=\"test\", alg=\"rsa-v1_5-sha256\"") + #expect(codeSigningInfo.signature == "12345") + #expect(codeSigningInfo.keyId == "test") + #expect(codeSigningInfo.algorithm == CodeSigningAlgorithm.RSA_SHA256) + } + + @Test + func `DefaultsKeyIdAndAlg`() throws { + let codeSigningInfo = try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "sig=\"12345\"") + #expect(codeSigningInfo.signature == "12345") + #expect(codeSigningInfo.keyId == "root") + #expect(codeSigningInfo.algorithm == CodeSigningAlgorithm.RSA_SHA256) + } + + @Test + func `ThrowsForInvalidAlg`() { + #expect { + try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "fake=\"12345\"") + } throws: { error in + guard case CodeSigningError.SignatureHeaderSigMissing = error else { + return false + } + return true + } + + #expect { + try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "fs=1") + } throws: { error in + guard case CodeSigningError.SignatureHeaderStructuredFieldParseError = error else { + return false + } + return true + } + + #expect { + try SignatureHeaderInfo.parseSignatureHeader(signatureHeader: "sig=\"12345\", alg=\"blah\"") + } throws: { error in + guard case CodeSigningError.AlgorithmParseError = error else { + return false + } + return true + } + } +} diff --git a/packages/expo-updates/ios/Tests/StringDictionarySpec.swift b/packages/expo-updates/ios/Tests/StringDictionarySpec.swift deleted file mode 100644 index 13db6f19c7729f..00000000000000 --- a/packages/expo-updates/ios/Tests/StringDictionarySpec.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -class StringDictionarySpec : ExpoSpec { - override class func spec() { - describe("serialization") { - it("validates") { - expect(try StringDictionary(value: ["hello": StringItem(value: "world")]).serialize()) == "hello=\"world\"" - expect(try StringDictionary(value: ["*hello": StringItem(value: "world")]).serialize()) == "*hello=\"world\"" - expect(try StringDictionary(value: ["*_-.*": StringItem(value: "")]).serialize()) == "*_-.*=\"\"" - - // escapes - expect(try StringDictionary(value: ["test": StringItem(value: "\\test\"")]).serialize()) == "test=\"\\\\test\\\"\"" - - // empty key - expect { - try StringDictionary(value: ["": StringItem(value: "world")]).serialize() - }.to(throwError(SerializerError.emptyKey)) - - // capital letter in key - expect { - try StringDictionary(value: ["Hello": StringItem(value: "world")]).serialize() - }.to(throwError(SerializerError.invalidCharacterInKey(key: "Hello", character: "H"))) - - // capital letter in key - expect { - try StringDictionary(value: ["Hello": StringItem(value: "world")]).serialize() - }.to(throwError(SerializerError.invalidCharacterInKey(key: "Hello", character: "H"))) - - // invalid character in key - expect { - try StringDictionary(value: ["Hell&o": StringItem(value: "world")]).serialize() - }.to(throwError(SerializerError.invalidCharacterInKey(key: "hell&o", character: "&"))) - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/StringDictionaryTests.swift b/packages/expo-updates/ios/Tests/StringDictionaryTests.swift new file mode 100644 index 00000000000000..be7e286d45ea1f --- /dev/null +++ b/packages/expo-updates/ios/Tests/StringDictionaryTests.swift @@ -0,0 +1,54 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +@Suite("StringDictionary serialization") +struct StringDictionaryTests { + @Test + func `validates basic cases`() throws { + #expect(try StringDictionary(value: ["hello": StringItem(value: "world")]).serialize() == "hello=\"world\"") + #expect(try StringDictionary(value: ["*hello": StringItem(value: "world")]).serialize() == "*hello=\"world\"") + #expect(try StringDictionary(value: ["*_-.*": StringItem(value: "")]).serialize() == "*_-.*=\"\"") + + // escapes + #expect(try StringDictionary(value: ["test": StringItem(value: "\\test\"")]).serialize() == "test=\"\\\\test\\\"\"") + } + + @Test + func `throws for empty key`() { + #expect { + try StringDictionary(value: ["": StringItem(value: "world")]).serialize() + } throws: { error in + guard case SerializerError.emptyKey = error else { + return false + } + return true + } + } + + @Test + func `throws for capital letter in key`() { + #expect { + try StringDictionary(value: ["Hello": StringItem(value: "world")]).serialize() + } throws: { error in + guard case SerializerError.invalidCharacterInKey(key: "Hello", character: "H") = error else { + return false + } + return true + } + } + + @Test + func `throws for invalid character in key`() { + #expect { + try StringDictionary(value: ["hell&o": StringItem(value: "world")]).serialize() + } throws: { error in + guard case SerializerError.invalidCharacterInKey(key: "hell&o", character: "&") = error else { + return false + } + return true + } + } +} diff --git a/packages/expo-updates/ios/Tests/StringItemSpec.swift b/packages/expo-updates/ios/Tests/StringItemSpec.swift deleted file mode 100644 index e76955be3f8886..00000000000000 --- a/packages/expo-updates/ios/Tests/StringItemSpec.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -class StringItemSpec : ExpoSpec { - override class func spec() { - describe("serialization") { - it("works in the normal case") { - expect(try StringItem(value: "hello").serialize()) == "\"hello\"" - } - - it("escapes") { - expect(try StringItem(value: "\\test\"").serialize()) == "\"\\\\test\\\"\"" - } - - it("validates") { - expect { - try StringItem(value: "hello" + String(Character.fromHex("10"))).serialize() - }.to(throwError(SerializerError.invalidCharacterInString(string: "hello" + String(Character.fromHex("10")), character: Character.fromHex("10")))) - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/StringItemTests.swift b/packages/expo-updates/ios/Tests/StringItemTests.swift new file mode 100644 index 00000000000000..3286c3d9088081 --- /dev/null +++ b/packages/expo-updates/ios/Tests/StringItemTests.swift @@ -0,0 +1,32 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +@Suite("StringItem serialization") +struct StringItemTests { + @Test + func `works in the normal case`() throws { + #expect(try StringItem(value: "hello").serialize() == "\"hello\"") + } + + @Test + func `escapes`() throws { + #expect(try StringItem(value: "\\test\"").serialize() == "\"\\\\test\\\"\"") + } + + @Test + func `validates`() { + let invalidString = "hello" + String(Character.fromHex("10")) + let invalidChar = Character.fromHex("10") + #expect { + try StringItem(value: invalidString).serialize() + } throws: { error in + guard case SerializerError.invalidCharacterInString(string: invalidString, character: invalidChar) = error else { + return false + } + return true + } + } +} diff --git a/packages/expo-updates/ios/Tests/StringListSpec.swift b/packages/expo-updates/ios/Tests/StringListSpec.swift deleted file mode 100644 index be0544c4ef127a..00000000000000 --- a/packages/expo-updates/ios/Tests/StringListSpec.swift +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -class StringListSpec : ExpoSpec { - override class func spec() { - describe("serialization") { - it("empty list") { - expect(StringList(value: []).serialize()) == "" - } - - it("basic list") { - expect(try StringList(value: [StringItem(value: "10"), StringItem(value: "20")]).serialize()) == "\"10\", \"20\"" - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/StringListTests.swift b/packages/expo-updates/ios/Tests/StringListTests.swift new file mode 100644 index 00000000000000..bb5ca9754b9b36 --- /dev/null +++ b/packages/expo-updates/ios/Tests/StringListTests.swift @@ -0,0 +1,18 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +@Suite("StringList serialization") +struct StringListTests { + @Test + func `empty list`() { + #expect(StringList(value: []).serialize() == "") + } + + @Test + func `basic list`() throws { + #expect(try StringList(value: [StringItem(value: "10"), StringItem(value: "20")]).serialize() == "\"10\", \"20\"") + } +} diff --git a/packages/expo-updates/ios/Tests/UpdateAssetSpec.swift b/packages/expo-updates/ios/Tests/UpdateAssetSpec.swift deleted file mode 100644 index af59b0849ebbd0..00000000000000 --- a/packages/expo-updates/ios/Tests/UpdateAssetSpec.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -import EXManifests - -class UpdateAssetSpec : ExpoSpec { - override class func spec() { - describe("filename") { - it("is overridable") { - let asset1 = UpdateAsset(key: nil, type: "bundle") - let asset2 = UpdateAsset(key: nil, type: "bundle") - expect(asset1) != asset2 - - let assetSetFilename = UpdateAsset(key: nil, type: "bundle") - let filenameFromDatabase = "filename.png" - assetSetFilename.filename = filenameFromDatabase - expect(assetSetFilename.filename) == filenameFromDatabase - } - - it("works with extension") { - let assetWithDotPrefix = UpdateAsset(key: "cat", type: ".jpeg") - expect(assetWithDotPrefix.filename) == "cat.jpeg" - - let assetWithoutDotPrefix = UpdateAsset(key: "cat", type: "jpeg") - expect(assetWithoutDotPrefix.filename) == "cat.jpeg" - - let assetWithoutKey = UpdateAsset(key: nil, type: "jpeg") - expect(assetWithoutKey.filename.dropFirst(assetWithoutKey.filename.count - 5)) == ".jpeg" - } - - it("works without extension") { - let assetWithDotPrefix = UpdateAsset(key: "cat", type: nil) - expect(assetWithDotPrefix.filename) == "cat" - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/UpdateAssetTests.swift b/packages/expo-updates/ios/Tests/UpdateAssetTests.swift new file mode 100644 index 00000000000000..3607cb2590a8de --- /dev/null +++ b/packages/expo-updates/ios/Tests/UpdateAssetTests.swift @@ -0,0 +1,40 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +import EXManifests + +@Suite("UpdateAsset filename") +struct UpdateAssetTests { + @Test + func `is overridable`() { + let asset1 = UpdateAsset(key: nil, type: "bundle") + let asset2 = UpdateAsset(key: nil, type: "bundle") + #expect(asset1 != asset2) + + let assetSetFilename = UpdateAsset(key: nil, type: "bundle") + let filenameFromDatabase = "filename.png" + assetSetFilename.filename = filenameFromDatabase + #expect(assetSetFilename.filename == filenameFromDatabase) + } + + @Test + func `works with extension`() { + let assetWithDotPrefix = UpdateAsset(key: "cat", type: ".jpeg") + #expect(assetWithDotPrefix.filename == "cat.jpeg") + + let assetWithoutDotPrefix = UpdateAsset(key: "cat", type: "jpeg") + #expect(assetWithoutDotPrefix.filename == "cat.jpeg") + + let assetWithoutKey = UpdateAsset(key: nil, type: "jpeg") + #expect(assetWithoutKey.filename.hasSuffix(".jpeg")) + } + + @Test + func `works without extension`() { + let assetWithDotPrefix = UpdateAsset(key: "cat", type: nil) + #expect(assetWithDotPrefix.filename == "cat") + } +} diff --git a/packages/expo-updates/ios/Tests/UpdateSpec.swift b/packages/expo-updates/ios/Tests/UpdateSpec.swift deleted file mode 100644 index cf04c49997734b..00000000000000 --- a/packages/expo-updates/ios/Tests/UpdateSpec.swift +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -import EXManifests - -class UpdateSpec : ExpoSpec { - override class func spec() { - let config = try! UpdatesConfig.config(fromDictionary: [ - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://u.expo.dev/00000000-0000-0000-0000-000000000000" - ]) - let database = UpdatesDatabase() - - describe("instantiation") { - it("throws for legacy manifest") { - let legacyManifest = [ - "sdkVersion": "39.0.0", - "releaseId": "0eef8214-4833-4089-9dff-b4138a14f196", - "commitTime": "2020-11-11T00:17:54.797Z", - "bundleUrl": "https://url.to/bundle.js" - ] - - let responseHeaderData = ResponseHeaderData( - protocolVersionRaw: nil, - serverDefinedHeadersRaw: nil, - manifestFiltersRaw: nil - ) - - expect { try Update.update( - withManifest: legacyManifest, - responseHeaderData: responseHeaderData, - extensions: [:], - config: config, - database: database - ) }.to(throwError()) - } - - it("works for expo updates manifest") { - let expoUpdatesManifest = [ - "runtimeVersion": "1", - "id": "0eef8214-4833-4089-9dff-b4138a14f196", - "createdAt": "2020-11-11T00:17:54.797Z", - "launchAsset": [ - "url": "https://url.to/bundle.js", - "contentType": "application/javascript" - ] - ] - - let responseHeaderData = ResponseHeaderData( - protocolVersionRaw: "0", - serverDefinedHeadersRaw: nil, - manifestFiltersRaw: nil - ) - - expect(try! Update.update( - withManifest: expoUpdatesManifest, - responseHeaderData: responseHeaderData, - extensions: [:], - config: config, - database: database - )).notTo(beNil()) - } - - it("throws for unsupported protocol version") { - let expoUpdatesManifest = [ - "runtimeVersion": "1", - "id": "0eef8214-4833-4089-9dff-b4138a14f196", - "createdAt": "2020-11-11T00:17:54.797Z", - "launchAsset": [ - "url": "https://url.to/bundle.js", - "contentType": "application/javascript" - ] - ] - - let responseHeaderData = ResponseHeaderData( - protocolVersionRaw: "2", - serverDefinedHeadersRaw: nil, - manifestFiltersRaw: nil - ) - - expect(try Update.update( - withManifest: expoUpdatesManifest, - responseHeaderData: responseHeaderData, - extensions: [:], - config: config, - database: database - )).to(throwError(UpdateError.invalidExpoProtocolVersion(protocolVersion: 2))) - } - - it("works for embedded bare manifest") { - let embeddedManifest = [ - "id": "0eef8214-4833-4089-9dff-b4138a14f196", - "commitTime": 1609975977832 - ] - expect(Update.update( - withRawEmbeddedManifest: embeddedManifest, - config: config, - database: database - )).notTo(beNil()) - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/UpdateTests.swift b/packages/expo-updates/ios/Tests/UpdateTests.swift new file mode 100644 index 00000000000000..b3d202f5931973 --- /dev/null +++ b/packages/expo-updates/ios/Tests/UpdateTests.swift @@ -0,0 +1,116 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +import EXManifests + +@Suite("Update instantiation") +struct UpdateTests { + let config = try! UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://u.expo.dev/00000000-0000-0000-0000-000000000000" + ]) + let database = UpdatesDatabase() + + @Test + func `throws for legacy manifest`() { + let legacyManifest = [ + "sdkVersion": "39.0.0", + "releaseId": "0eef8214-4833-4089-9dff-b4138a14f196", + "commitTime": "2020-11-11T00:17:54.797Z", + "bundleUrl": "https://url.to/bundle.js" + ] + + let responseHeaderData = ResponseHeaderData( + protocolVersionRaw: nil, + serverDefinedHeadersRaw: nil, + manifestFiltersRaw: nil + ) + + #expect(throws: (any Error).self) { + try Update.update( + withManifest: legacyManifest, + responseHeaderData: responseHeaderData, + extensions: [:], + config: config, + database: database + ) + } + } + + @Test + func `works for expo updates manifest`() throws { + let expoUpdatesManifest: [String: Any] = [ + "runtimeVersion": "1", + "id": "0eef8214-4833-4089-9dff-b4138a14f196", + "createdAt": "2020-11-11T00:17:54.797Z", + "launchAsset": [ + "url": "https://url.to/bundle.js", + "contentType": "application/javascript" + ] + ] + + let responseHeaderData = ResponseHeaderData( + protocolVersionRaw: "0", + serverDefinedHeadersRaw: nil, + manifestFiltersRaw: nil + ) + + _ = try Update.update( + withManifest: expoUpdatesManifest, + responseHeaderData: responseHeaderData, + extensions: [:], + config: config, + database: database + ) + } + + @Test + func `throws for unsupported protocol version`() { + let expoUpdatesManifest: [String: Any] = [ + "runtimeVersion": "1", + "id": "0eef8214-4833-4089-9dff-b4138a14f196", + "createdAt": "2020-11-11T00:17:54.797Z", + "launchAsset": [ + "url": "https://url.to/bundle.js", + "contentType": "application/javascript" + ] + ] + + let responseHeaderData = ResponseHeaderData( + protocolVersionRaw: "2", + serverDefinedHeadersRaw: nil, + manifestFiltersRaw: nil + ) + + #expect { + try Update.update( + withManifest: expoUpdatesManifest, + responseHeaderData: responseHeaderData, + extensions: [:], + config: config, + database: database + ) + } throws: { error in + guard case UpdateError.invalidExpoProtocolVersion(protocolVersion: 2) = error else { + return false + } + return true + } + } + + @Test + func `works for embedded bare manifest`() { + let embeddedManifest: [String: Any] = [ + "id": "0eef8214-4833-4089-9dff-b4138a14f196", + "commitTime": 1609975977832 + ] + _ = Update.update( + withRawEmbeddedManifest: embeddedManifest, + config: config, + database: database + ) + } +} diff --git a/packages/expo-updates/ios/Tests/UpdatesConfigSpec.swift b/packages/expo-updates/ios/Tests/UpdatesConfigSpec.swift deleted file mode 100644 index 4d9d2b7077feb9..00000000000000 --- a/packages/expo-updates/ios/Tests/UpdatesConfigSpec.swift +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -import EXManifests - -class UpdatesConfigSpecForBundle {} - -class UpdatesConfigSpec : ExpoSpec { - override class func spec() { - describe("instantiation from plist") { - it("instantiates") { - let bundle = Bundle(for: UpdatesConfigSpecForBundle.self) - let configPlistPath = bundle.path(forResource: "TestConfig", ofType: "plist")! - guard let configNSDictionary = NSDictionary(contentsOfFile: configPlistPath) as? [String: Any] else { - throw UpdatesConfigError.ExpoUpdatesConfigPlistError - } - let config = try! UpdatesConfig.config(fromDictionary: configNSDictionary) - expect(config.scopeKey) == "blah" - expect(config.updateUrl.absoluteString) == "http://example.com" - expect(config.requestHeaders) == ["Hello": "World"] - expect(config.launchWaitMs) == 2 - expect(config.checkOnLaunch) == .ErrorRecoveryOnly - expect(config.codeSigningConfiguration).toNot(beNil()) - expect(config.enableExpoUpdatesProtocolV0CompatibilityMode) == false - expect(config.enableBsdiffPatchSupport) == false - expect(config.runtimeVersion) == "fake-version-1" - expect(config.hasEmbeddedUpdate) == true - } - - it("overrides with merging-in map") { - let bundle = Bundle(for: UpdatesConfigSpecForBundle.self) - let configPlistPath = bundle.path(forResource: "TestConfig", ofType: "plist")! - - // test overriding various keys - let otherDictionary = [ - UpdatesConfig.EXUpdatesConfigEnabledKey: false, - UpdatesConfig.EXUpdatesConfigScopeKeyKey: "overridden", - UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "overridden", - UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "http://google.com", - UpdatesConfig.EXUpdatesConfigRequestHeadersKey: ["Foo": "Bar"], - UpdatesConfig.EXUpdatesConfigEnableBsdiffPatchSupportKey: false, - ] - - guard let configNSDictionary = NSDictionary(contentsOfFile: configPlistPath) as? [String: Any] else { - throw UpdatesConfigError.ExpoUpdatesConfigPlistError - } - - var dictionary: [String: Any] = configNSDictionary.merging(otherDictionary, uniquingKeysWith: { _, new in new }) - - let config = try! UpdatesConfig.config(fromDictionary: dictionary) - expect(config.scopeKey) == "overridden" - expect(config.updateUrl.absoluteString) == "http://google.com" - expect(config.requestHeaders) == ["Foo": "Bar"] - expect(config.launchWaitMs) == 2 - expect(config.checkOnLaunch) == .ErrorRecoveryOnly - expect(config.codeSigningConfiguration).toNot(beNil()) - expect(config.enableExpoUpdatesProtocolV0CompatibilityMode) == false - expect(config.enableBsdiffPatchSupport) == false - expect(config.runtimeVersion) == "overridden" - expect(config.hasEmbeddedUpdate) == true - } - } - - describe("normalizedURLOrigin") { - it("is correct with no port") { - let urlNoPort = URL(string: "https://exp.host/test")! - expect(UpdatesConfig.normalizedURLOrigin(url: urlNoPort)) == "https://exp.host" - } - - it("is correct with default port") { - let urlDefaultPort = URL(string: "https://exp.host:443/test")! - expect(UpdatesConfig.normalizedURLOrigin(url: urlDefaultPort)) == "https://exp.host" - } - - it("is correct with other port") { - let urlOtherPort = URL(string: "https://exp.host:47/test")! - expect(UpdatesConfig.normalizedURLOrigin(url: urlOtherPort)) == "https://exp.host:47" - } - } - - describe("isValidRequestHeadersOverride") { - it("should return true for headers matched with embedded headers") { - let originalHeaders = ["expo-channel-name": "default"] - let requestHeadersOverride = ["Expo-Channel-Name": "preview"] - let result = UpdatesConfig.isValidRequestHeadersOverride( - originalEmbeddedRequestHeaders: originalHeaders, - requestHeadersOverride: requestHeadersOverride - ) - expect(result) == true - } - - it("should return false for headers unmatched with embedded headers") { - let originalHeaders = ["expo-channel-name": "default"] - let requestHeadersOverride = [ - "Expo-Channel-Name": "preview", - "X-Custom": "custom" - ] - let result = UpdatesConfig.isValidRequestHeadersOverride( - originalEmbeddedRequestHeaders: originalHeaders, - requestHeadersOverride: requestHeadersOverride - ) - expect(result) == false - } - - it("should return false for Host override header") { - let originalHeaders = [ - "expo-channel-name": "default", - "Host": "example.org" - ] - let requestHeadersOverride = [ - "Expo-Channel-Name": "preview", - "Host": "override.org" - ] - let result = UpdatesConfig.isValidRequestHeadersOverride( - originalEmbeddedRequestHeaders: originalHeaders, - requestHeadersOverride: requestHeadersOverride - ) - expect(result) == false - } - - it("should handle Host override header normalization") { - let originalHeaders = [ - "expo-channel-name": "default", - " Host ": "example.org" - ] - let requestHeadersOverride = [ - "Expo-Channel-Name": "preview", - " Host ": "override.org" - ] - let result = UpdatesConfig.isValidRequestHeadersOverride( - originalEmbeddedRequestHeaders: originalHeaders, - requestHeadersOverride: requestHeadersOverride - ) - expect(result) == false - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/UpdatesConfigTests.swift b/packages/expo-updates/ios/Tests/UpdatesConfigTests.swift new file mode 100644 index 00000000000000..bedbd60ed8de61 --- /dev/null +++ b/packages/expo-updates/ios/Tests/UpdatesConfigTests.swift @@ -0,0 +1,149 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +import EXManifests + +class UpdatesConfigTestsForBundle {} + +@Suite("UpdatesConfig") +struct UpdatesConfigTests { + // MARK: - instantiation from plist + + @Test + func `instantiates from plist`() throws { + let bundle = Bundle(for: UpdatesConfigTestsForBundle.self) + let configPlistPath = bundle.path(forResource: "TestConfig", ofType: "plist")! + guard let configNSDictionary = NSDictionary(contentsOfFile: configPlistPath) as? [String: Any] else { + throw UpdatesConfigError.ExpoUpdatesConfigPlistError + } + let config = try UpdatesConfig.config(fromDictionary: configNSDictionary) + #expect(config.scopeKey == "blah") + #expect(config.updateUrl.absoluteString == "http://example.com") + #expect(config.requestHeaders == ["Hello": "World"]) + #expect(config.launchWaitMs == 2) + #expect(config.checkOnLaunch == .ErrorRecoveryOnly) + #expect(config.codeSigningConfiguration != nil) + #expect(config.enableExpoUpdatesProtocolV0CompatibilityMode == false) + #expect(config.enableBsdiffPatchSupport == false) + #expect(config.runtimeVersion == "fake-version-1") + #expect(config.hasEmbeddedUpdate == true) + } + + @Test + func `overrides with merging-in map`() throws { + let bundle = Bundle(for: UpdatesConfigTestsForBundle.self) + let configPlistPath = bundle.path(forResource: "TestConfig", ofType: "plist")! + + // test overriding various keys + let otherDictionary: [String: Any] = [ + UpdatesConfig.EXUpdatesConfigEnabledKey: false, + UpdatesConfig.EXUpdatesConfigScopeKeyKey: "overridden", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "overridden", + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "http://google.com", + UpdatesConfig.EXUpdatesConfigRequestHeadersKey: ["Foo": "Bar"], + UpdatesConfig.EXUpdatesConfigEnableBsdiffPatchSupportKey: false, + ] + + guard let configNSDictionary = NSDictionary(contentsOfFile: configPlistPath) as? [String: Any] else { + throw UpdatesConfigError.ExpoUpdatesConfigPlistError + } + + let dictionary: [String: Any] = configNSDictionary.merging(otherDictionary, uniquingKeysWith: { _, new in new }) + + let config = try UpdatesConfig.config(fromDictionary: dictionary) + #expect(config.scopeKey == "overridden") + #expect(config.updateUrl.absoluteString == "http://google.com") + #expect(config.requestHeaders == ["Foo": "Bar"]) + #expect(config.launchWaitMs == 2) + #expect(config.checkOnLaunch == .ErrorRecoveryOnly) + #expect(config.codeSigningConfiguration != nil) + #expect(config.enableExpoUpdatesProtocolV0CompatibilityMode == false) + #expect(config.enableBsdiffPatchSupport == false) + #expect(config.runtimeVersion == "overridden") + #expect(config.hasEmbeddedUpdate == true) + } + + // MARK: - normalizedURLOrigin + + @Test + func `normalizedURLOrigin is correct with no port`() { + let urlNoPort = URL(string: "https://exp.host/test")! + #expect(UpdatesConfig.normalizedURLOrigin(url: urlNoPort) == "https://exp.host") + } + + @Test + func `normalizedURLOrigin is correct with default port`() { + let urlDefaultPort = URL(string: "https://exp.host:443/test")! + #expect(UpdatesConfig.normalizedURLOrigin(url: urlDefaultPort) == "https://exp.host") + } + + @Test + func `normalizedURLOrigin is correct with other port`() { + let urlOtherPort = URL(string: "https://exp.host:47/test")! + #expect(UpdatesConfig.normalizedURLOrigin(url: urlOtherPort) == "https://exp.host:47") + } + + // MARK: - isValidRequestHeadersOverride + + @Test + func `should return true for headers matched with embedded headers`() { + let originalHeaders = ["expo-channel-name": "default"] + let requestHeadersOverride = ["Expo-Channel-Name": "preview"] + let result = UpdatesConfig.isValidRequestHeadersOverride( + originalEmbeddedRequestHeaders: originalHeaders, + requestHeadersOverride: requestHeadersOverride + ) + #expect(result == true) + } + + @Test + func `should return false for headers unmatched with embedded headers`() { + let originalHeaders = ["expo-channel-name": "default"] + let requestHeadersOverride = [ + "Expo-Channel-Name": "preview", + "X-Custom": "custom" + ] + let result = UpdatesConfig.isValidRequestHeadersOverride( + originalEmbeddedRequestHeaders: originalHeaders, + requestHeadersOverride: requestHeadersOverride + ) + #expect(result == false) + } + + @Test + func `should return false for Host override header`() { + let originalHeaders = [ + "expo-channel-name": "default", + "Host": "example.org" + ] + let requestHeadersOverride = [ + "Expo-Channel-Name": "preview", + "Host": "override.org" + ] + let result = UpdatesConfig.isValidRequestHeadersOverride( + originalEmbeddedRequestHeaders: originalHeaders, + requestHeadersOverride: requestHeadersOverride + ) + #expect(result == false) + } + + @Test + func `should handle Host override header normalization`() { + let originalHeaders = [ + "expo-channel-name": "default", + " Host ": "example.org" + ] + let requestHeadersOverride = [ + "Expo-Channel-Name": "preview", + " Host ": "override.org" + ] + let result = UpdatesConfig.isValidRequestHeadersOverride( + originalEmbeddedRequestHeaders: originalHeaders, + requestHeadersOverride: requestHeadersOverride + ) + #expect(result == false) + } +} diff --git a/packages/expo-updates/ios/Tests/UpdatesLogReaderSpec.swift b/packages/expo-updates/ios/Tests/UpdatesLogReaderSpec.swift deleted file mode 100644 index a467b43abe23cb..00000000000000 --- a/packages/expo-updates/ios/Tests/UpdatesLogReaderSpec.swift +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2022 650 Industries, Inc. All rights reserved. - -import ExpoModulesTestCore - -@testable import EXUpdates - -@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) -class UpdatesLogReaderSpec : ExpoSpec { - override class func spec() { - beforeEach { - clearLogSync() - } - - it("PurgeOldLogs") { - let logReader = UpdatesLogReader() - - let date1 = Date() - purgeEntriesSync(logReader: logReader, olderThan: date1) - - logErrorSync(message: "Test message", code: .noUpdatesAvailable) - RunLoop.current.run(until: Date().addingTimeInterval(1)) - - let date2 = Date() - logWarnSync(message: "Test message", code: .assetsFailedToLoad, updateId: "myUpdateId", assetId: "myAssetId") - - let entries1: [String] = logReader.getLogEntries(newerThan: date1) - .filter {entryString in - entryString.contains("Test message") - } - XCTAssertEqual(2, entries1.count) - - let entries2: [String] = logReader.getLogEntries(newerThan: date2) - .filter {entryString in - entryString.contains("Test message") - } - XCTAssertEqual(1, entries2.count) - - purgeEntriesSync(logReader: logReader, olderThan: date2) - - let entries3: [String] = logReader.getLogEntries(newerThan: date1) - .filter {entryString in - entryString.contains("Test message") - } - - XCTAssertEqual(1, entries3.count) - } - - // MARK: - - Private methods - - func clearLogSync() { - waitUntil(timeout: .milliseconds(500)) { done in - let persistentLog = PersistentFileLog(category: UpdatesLogger.EXPO_UPDATES_LOG_CATEGORY) - persistentLog.clearEntries { _ in - done() - } - } - } - - func logErrorSync(message: String, code: UpdatesErrorCode) { - waitUntil(timeout: .milliseconds(500)) { done in - let persistentLog = PersistentFileLog(category: UpdatesLogger.EXPO_UPDATES_LOG_CATEGORY) - let logEntryString = "xx" + UpdatesLogger().logEntryString(message: message, code: code, level: .error, duration: nil, updateId: nil, assetId: nil) - persistentLog.appendEntry(entry: logEntryString) {_ in - done() - } - } - } - - func logWarnSync(message: String, code: UpdatesErrorCode, updateId: String?, assetId: String?) { - waitUntil(timeout: .milliseconds(500)) { done in - let persistentLog = PersistentFileLog(category: UpdatesLogger.EXPO_UPDATES_LOG_CATEGORY) - let logEntryString = "xx" + UpdatesLogger().logEntryString(message: message, code: code, level: .warn, duration: nil, updateId: updateId, assetId: assetId) - persistentLog.appendEntry(entry: logEntryString) {_ in - done() - } - } - } - - func purgeEntriesSync(logReader: UpdatesLogReader, olderThan: Date) { - waitUntil(timeout: .milliseconds(500)) { done in - logReader.purgeLogEntries(olderThan: olderThan) { _ in - done() - } - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/UpdatesLogReaderTests.swift b/packages/expo-updates/ios/Tests/UpdatesLogReaderTests.swift new file mode 100644 index 00000000000000..0d871b95b0ab46 --- /dev/null +++ b/packages/expo-updates/ios/Tests/UpdatesLogReaderTests.swift @@ -0,0 +1,87 @@ +// Copyright (c) 2022 650 Industries, Inc. All rights reserved. + +import Testing +import ExpoModulesCore +@testable import EXUpdates + +@Suite("UpdatesLogReader", .serialized) +struct UpdatesLogReaderTests { + init() async { + await clearLogAsync() + } + + @Test + @MainActor + func `PurgeOldLogs`() async throws { + let logReader = UpdatesLogReader() + + let date1 = Date() + await purgeEntriesAsync(logReader: logReader, olderThan: date1) + + await logErrorAsync(message: "Test message", code: .noUpdatesAvailable) + try await Task.sleep(nanoseconds: 1_000_000_000) + + let date2 = Date() + await logWarnAsync(message: "Test message", code: .assetsFailedToLoad, updateId: "myUpdateId", assetId: "myAssetId") + + let entries1: [String] = logReader.getLogEntries(newerThan: date1) + .filter { entryString in + entryString.contains("Test message") + } + #expect(entries1.count == 2) + + let entries2: [String] = logReader.getLogEntries(newerThan: date2) + .filter { entryString in + entryString.contains("Test message") + } + #expect(entries2.count == 1) + + await purgeEntriesAsync(logReader: logReader, olderThan: date2) + + let entries3: [String] = logReader.getLogEntries(newerThan: date1) + .filter { entryString in + entryString.contains("Test message") + } + + #expect(entries3.count == 1) + } + + // MARK: - Private methods + + func clearLogAsync() async { + await withCheckedContinuation { continuation in + let persistentLog = PersistentFileLog(category: UpdatesLogger.EXPO_UPDATES_LOG_CATEGORY) + persistentLog.clearEntries { _ in + continuation.resume() + } + } + } + + func logErrorAsync(message: String, code: UpdatesErrorCode) async { + await withCheckedContinuation { continuation in + let persistentLog = PersistentFileLog(category: UpdatesLogger.EXPO_UPDATES_LOG_CATEGORY) + let logEntryString = "xx" + UpdatesLogger().logEntryString(message: message, code: code, level: .error, duration: nil, updateId: nil, assetId: nil) + persistentLog.appendEntry(entry: logEntryString) { _ in + continuation.resume() + } + } + } + + func logWarnAsync(message: String, code: UpdatesErrorCode, updateId: String?, assetId: String?) async { + await withCheckedContinuation { continuation in + let persistentLog = PersistentFileLog(category: UpdatesLogger.EXPO_UPDATES_LOG_CATEGORY) + let logEntryString = "xx" + UpdatesLogger().logEntryString(message: message, code: code, level: .warn, duration: nil, updateId: updateId, assetId: assetId) + persistentLog.appendEntry(entry: logEntryString) { _ in + continuation.resume() + } + } + } + + func purgeEntriesAsync(logReader: UpdatesLogReader, olderThan: Date) async { + await withCheckedContinuation { continuation in + logReader.purgeLogEntries(olderThan: olderThan) { _ in + continuation.resume() + } + } + } +} diff --git a/packages/expo-updates/ios/Tests/UpdatesMultipartStreamReaderSpec.swift b/packages/expo-updates/ios/Tests/UpdatesMultipartStreamReaderTests.swift similarity index 57% rename from packages/expo-updates/ios/Tests/UpdatesMultipartStreamReaderSpec.swift rename to packages/expo-updates/ios/Tests/UpdatesMultipartStreamReaderTests.swift index 10ec2781db5503..1757376550ecca 100644 --- a/packages/expo-updates/ios/Tests/UpdatesMultipartStreamReaderSpec.swift +++ b/packages/expo-updates/ios/Tests/UpdatesMultipartStreamReaderTests.swift @@ -1,13 +1,13 @@ // Copyright (c) 2020 650 Industries, Inc. All rights reserved. -import XCTest +import Testing + @testable import EXUpdates -/** - * Tests for UpdatesMultipartStreamReader - */ -class UpdatesMultipartStreamReaderSpec: XCTestCase { - func testSimpleCase() { +@Suite("UpdatesMultipartStreamReader") +struct UpdatesMultipartStreamReaderTests { + @Test + func `simple case`() { let response = "preamble, should be ignored\r\n" + "--sample_boundary\r\n" + "Content-Type: application/json; charset=utf-8\r\n" + @@ -21,17 +21,18 @@ class UpdatesMultipartStreamReaderSpec: XCTestCase { var count = 0 let success = reader.readAllParts { headers, content, done in - XCTAssertTrue(done) - XCTAssertEqual(headers?["Content-Type"] as? String, "application/json; charset=utf-8") - XCTAssertEqual(String(data: content!, encoding: .utf8), "{}") + #expect(done == true) + #expect(headers?["Content-Type"] as? String == "application/json; charset=utf-8") + #expect(String(data: content!, encoding: .utf8) == "{}") count += 1 } - XCTAssertTrue(success) - XCTAssertEqual(count, 1) + #expect(success == true) + #expect(count == 1) } - func testMultipleParts() { + @Test + func `multiple parts`() { let response = "preamble, should be ignored\r\n" + "--sample_boundary\r\n" + "1\r\n" + @@ -51,23 +52,21 @@ class UpdatesMultipartStreamReaderSpec: XCTestCase { let success = reader.readAllParts { _, content, done in if count < expectedContents.count { let expectedContent = expectedContents[count] - XCTAssertEqual(String(data: content!, encoding: .utf8), expectedContent) - XCTAssertEqual(done, count == expectedContents.count - 1) + #expect(String(data: content!, encoding: .utf8) == expectedContent) + #expect(done == (count == expectedContents.count - 1)) count += 1 } } - XCTAssertTrue(success) - XCTAssertEqual(count, 3) + #expect(success == true) + #expect(count == 3) } - func testNoDelimiter() { + @Test + func `no delimiter`() throws { let response = "content with no delimiter" - guard let responseData = response.data(using: .utf8) else { - XCTFail("Failed to convert response to UTF-8 data") - return - } + let responseData = try #require(response.data(using: .utf8)) let inputStream = InputStream(data: responseData) let reader = UpdatesMultipartStreamReader(inputStream: inputStream, boundary: "sample_boundary") @@ -76,67 +75,61 @@ class UpdatesMultipartStreamReaderSpec: XCTestCase { count += 1 } - XCTAssertFalse(success) - XCTAssertEqual(count, 0) + #expect(success == false) + #expect(count == 0) } - func testEmptyContent() { + @Test + func `empty content`() throws { let response = "--sample_boundary\r\n" + "Content-Type: application/json\r\n" + "\r\n" + "\r\n" + "--sample_boundary--\r\n" - guard let responseData = response.data(using: .utf8) else { - XCTFail("Failed to convert response to UTF-8 data") - return - } + let responseData = try #require(response.data(using: .utf8)) let inputStream = InputStream(data: responseData) let reader = UpdatesMultipartStreamReader(inputStream: inputStream, boundary: "sample_boundary") var count = 0 let success = reader.readAllParts { headers, content, done in - XCTAssertTrue(done) - XCTAssertEqual(headers?["Content-Type"] as? String, "application/json") - guard let contentData = content else { - XCTFail("Content should not be nil") - return + #expect(done == true) + #expect(headers?["Content-Type"] as? String == "application/json") + #expect(content != nil) + if let contentData = content { + #expect(String(data: contentData, encoding: .utf8) == "") } - XCTAssertEqual(String(data: contentData, encoding: .utf8), "") count += 1 } - XCTAssertTrue(success) - XCTAssertEqual(count, 1) + #expect(success == true) + #expect(count == 1) } - func testFirstBoundaryAsBoundary() { + @Test + func `first boundary as boundary`() throws { let response = "--sample_boundary\r\n" + "Content-Type: application/json\r\n" + "\r\n" + "{}\r\n" + "--sample_boundary--\r\n" - guard let responseData = response.data(using: .utf8) else { - XCTFail("Failed to convert response to UTF-8 data") - return - } + let responseData = try #require(response.data(using: .utf8)) let inputStream = InputStream(data: responseData) let reader = UpdatesMultipartStreamReader(inputStream: inputStream, boundary: "sample_boundary") var count = 0 let success = reader.readAllParts { headers, content, done in - XCTAssertTrue(done) - XCTAssertEqual(headers?["Content-Type"] as? String, "application/json") - guard let contentData = content else { - XCTFail("Content should not be nil") - return + #expect(done == true) + #expect(headers?["Content-Type"] as? String == "application/json") + #expect(content != nil) + if let contentData = content { + #expect(String(data: contentData, encoding: .utf8) == "{}") } - XCTAssertEqual(String(data: contentData, encoding: .utf8), "{}") count += 1 } - XCTAssertTrue(success) - XCTAssertEqual(count, 1) + #expect(success == true) + #expect(count == 1) } } diff --git a/packages/expo-updates/ios/Tests/UpdatesParameterParserSpec.swift b/packages/expo-updates/ios/Tests/UpdatesParameterParserSpec.swift deleted file mode 100644 index f4b1076d38185a..00000000000000 --- a/packages/expo-updates/ios/Tests/UpdatesParameterParserSpec.swift +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2020 650 Industries, Inc. All rights reserved. - -import XCTest -@testable import EXUpdates - -class UpdatesParameterParserSpec: XCTestCase { - func testParameterParser() { - let testCases: [(String, [String: Any])] = [ - ("", [:]), - ("test; test1 = stuff ; test2 = \"stuff; stuff\"; test3=\"stuff", - ["test": NSNull(), "test1": "stuff", "test2": "stuff; stuff", "test3": "\"stuff"]), - (" test ; test1=stuff ; ; test2=; test3; ", - ["test": NSNull(), "test1": "stuff", "test2": NSNull(), "test3": NSNull()]), - (" test", ["test": NSNull()]), - (" ", [:]), - (" = stuff ", [:]), - ("text/plain; Charset=UTF-8", ["text/plain": NSNull(), "Charset": "UTF-8"]), - ("param = \"stuff\\\"; more stuff\"", ["param": "stuff\\\"; more stuff"]), - ("param = \"stuff\\\\\"; anotherparam", ["param": "stuff\\\\", "anotherparam": NSNull()]), - ("foo/bar; param=\"baz=bat\"", ["foo/bar": NSNull(), "param": "baz=bat"]), - - // Expo-specific tests - ("multipart/mixed; boundary=BbC04y", ["multipart/mixed": NSNull(), "boundary": "BbC04y"]), - ("form-data; name=\"manifest\"; filename=\"hello2\"", ["form-data": NSNull(), "name": "manifest", "filename": "hello2"]) - ] - - let parser = UpdatesParameterParser() - - for (parameterString, expectedDictionary) in testCases { - let parameters = parser.parseParameterString(parameterString, withDelimiter: ";") - - XCTAssertEqual(parameters.count, expectedDictionary.count, - "Parameter count mismatch for: '\(parameterString)'") - - for (key, expectedValue) in expectedDictionary { - XCTAssertTrue(parameters.keys.contains(key), - "Missing key '\(key)' for: '\(parameterString)'") - - let actualValue = parameters[key] - - if expectedValue is NSNull && actualValue is NSNull { - continue - } else if let expectedString = expectedValue as? String, - let actualString = actualValue as? String { - XCTAssertEqual(actualString, expectedString, - "Value mismatch for key '\(key)' in: '\(parameterString)'") - } else { - XCTFail("Type mismatch for key '\(key)' in: '\(parameterString)'. Expected: \(type(of: expectedValue)), Actual: \(type(of: actualValue ?? "nil"))") - } - } - } - } -} diff --git a/packages/expo-updates/ios/Tests/UpdatesParameterParserTests.swift b/packages/expo-updates/ios/Tests/UpdatesParameterParserTests.swift new file mode 100644 index 00000000000000..4f68664698a260 --- /dev/null +++ b/packages/expo-updates/ios/Tests/UpdatesParameterParserTests.swift @@ -0,0 +1,55 @@ +// Copyright (c) 2020 650 Industries, Inc. All rights reserved. + +import Testing + +@testable import EXUpdates + +@Suite("UpdatesParameterParser") +struct UpdatesParameterParserTests { + static let testCases: [(String, [String: Any])] = [ + ("", [:]), + ("test; test1 = stuff ; test2 = \"stuff; stuff\"; test3=\"stuff", + ["test": NSNull(), "test1": "stuff", "test2": "stuff; stuff", "test3": "\"stuff"]), + (" test ; test1=stuff ; ; test2=; test3; ", + ["test": NSNull(), "test1": "stuff", "test2": NSNull(), "test3": NSNull()]), + (" test", ["test": NSNull()]), + (" ", [:]), + (" = stuff ", [:]), + ("text/plain; Charset=UTF-8", ["text/plain": NSNull(), "Charset": "UTF-8"]), + ("param = \"stuff\\\"; more stuff\"", ["param": "stuff\\\"; more stuff"]), + ("param = \"stuff\\\\\"; anotherparam", ["param": "stuff\\\\", "anotherparam": NSNull()]), + ("foo/bar; param=\"baz=bat\"", ["foo/bar": NSNull(), "param": "baz=bat"]), + // Expo-specific tests + ("multipart/mixed; boundary=BbC04y", ["multipart/mixed": NSNull(), "boundary": "BbC04y"]), + ("form-data; name=\"manifest\"; filename=\"hello2\"", ["form-data": NSNull(), "name": "manifest", "filename": "hello2"]) + ] + + @Test + func `parses parameter strings`() { + let parser = UpdatesParameterParser() + + for (parameterString, expectedDictionary) in Self.testCases { + let parameters = parser.parseParameterString(parameterString, withDelimiter: ";") + + #expect(parameters.count == expectedDictionary.count, + "Parameter count mismatch for: '\(parameterString)'") + + for (key, expectedValue) in expectedDictionary { + #expect(parameters.keys.contains(key), + "Missing key '\(key)' for: '\(parameterString)'") + + let actualValue = parameters[key] + + if expectedValue is NSNull && actualValue is NSNull { + continue + } else if let expectedString = expectedValue as? String, + let actualString = actualValue as? String { + #expect(actualString == expectedString, + "Value mismatch for key '\(key)' in: '\(parameterString)'") + } else { + Issue.record("Type mismatch for key '\(key)' in: '\(parameterString)'. Expected: \(type(of: expectedValue)), Actual: \(type(of: actualValue ?? "nil" as Any))") + } + } + } + } +}