diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md index 11f65d46e7b2a2..d55856378243cb 100644 --- a/packages/@expo/cli/CHANGELOG.md +++ b/packages/@expo/cli/CHANGELOG.md @@ -55,6 +55,7 @@ - [Internal] Resolve the dev server port once instead of re-deriving it, and read the URL environment variables outside the `UrlCreator` ([#48236](https://github.com/expo/expo/pull/48236) by [@ramonclaudio](https://github.com/ramonclaudio)) - Update `expo start --private-key-path` help text ([#47795](https://github.com/expo/expo/pull/47795) by [@kitten](https://github.com/kitten)) - Re-enable sextant QR code for Zed ([#48382](https://github.com/expo/expo/pull/48382) by [@mchisolm0](https://github.com/mchisolm0)) +- [Internal] Fix the flaky `waitForActionAsync` timeout test by driving it with fake timers instead of real time. ([#48811](https://github.com/expo/expo/pull/48811) by [@expo-bot](https://github.com/expo-bot)) - Bump to `multitars@1.0.2` to address symlink and unicode bugs ([#48833](https://github.com/expo/expo/pull/48833) by [@kitten](https://github.com/kitten)) - [Internal] Move static HTML asset injection into `getStaticContent()`. ([#47006](https://github.com/expo/expo/pull/47006) by [@hassankhan](https://github.com/hassankhan)) - Prewarm Metro transform workers while waiting for the first development bundle request ([#48836](https://github.com/expo/expo/pull/48836) by [@kitten](https://github.com/kitten)) diff --git a/packages/@expo/cli/src/utils/__tests__/delay-test.ts b/packages/@expo/cli/src/utils/__tests__/delay-test.ts index 1aa254b477b855..4f4281e5df240d 100644 --- a/packages/@expo/cli/src/utils/__tests__/delay-test.ts +++ b/packages/@expo/cli/src/utils/__tests__/delay-test.ts @@ -25,15 +25,26 @@ describe(waitForActionAsync, () => { expect(fn).toHaveBeenCalledTimes(1); }); it(`times out waiting for a given action to return a truthy value`, async () => { + // Fake timers keep the number of polls deterministic. On real timers the + // action runs twice only while two 80ms intervals still fit in the 100ms + // max wait time. A busy machine can delay the first interval past 100ms, + // and then the action runs once. + jest.useFakeTimers(); const fn = jest.fn(() => ''); - const result = await waitForActionAsync({ + + const promise = waitForActionAsync({ action: fn, interval: 80, maxWaitTime: 100, }); - expect(result).toEqual(''); + // First interval: 80ms elapsed, still inside the max wait time. + await jest.advanceTimersByTimeAsync(80); + // Second interval: 160ms elapsed, so the loop stops. + await jest.advanceTimersByTimeAsync(80); + + await expect(promise).resolves.toEqual(''); expect(fn).toHaveBeenCalledTimes(2); - }, 500); + }); }); describe(resolveWithTimeout, () => { diff --git a/packages/expo-updates/CHANGELOG.md b/packages/expo-updates/CHANGELOG.md index 3390f8656a661c..a6a9ce6de09027 100644 --- a/packages/expo-updates/CHANGELOG.md +++ b/packages/expo-updates/CHANGELOG.md @@ -13,6 +13,7 @@ ### 🐛 Bug fixes +- [iOS] Fix the embedded manifest recording the wrong `packagerHash` for assets that ship scale variants iOS does not allow (such as `@1.5x` and `@4x`): the hashes were read by the filtered scale index instead of the asset's own, so those images resolved to an empty URI and rendered blank in release builds. ([#48811](https://github.com/expo/expo/pull/48811) by [@expo-bot](https://github.com/expo-bot)) - [iOS] Fix `expo-dev-client` being detected as installed when it is absent, which enabled `USE_DEV_CLIENT` and printed a `MODULE_NOT_FOUND` trace during `pod install`. ([#49233](https://github.com/expo/expo/pull/49233) by [@dennytosp](https://github.com/dennytosp)) - [iOS] Set `always_out_of_date` on the `Generate updates resources for expo-updates` script_phase to silence the Xcode "run script phase will run on every build" dependency-analysis warning. ([#47622](https://github.com/expo/expo/pull/47622) by [@ramonclaudio](https://github.com/ramonclaudio)) - [iOS] Fix two launch crashes reachable when a native module resolves the updates controller before `start()` runs: reading `launchedUpdateId` / `launchAssetPath` / `launchAssetUrl()` trapped on an implicitly-unwrapped optional, and the unsynchronized `stateChangeListeners` dictionary could fault while `UpdatesStateMachine` iterated it. ([#48898](https://github.com/expo/expo/pull/48898) by [@spsaucier](https://github.com/spsaucier)) @@ -26,6 +27,11 @@ - [Android] Register the embedded update in a single transaction. An interrupted registration previously left an update row with no launch asset, which is treated as launchable and then fails every cold start with "Launch asset not found for update"; it now leaves no row, so the next launch registers it cleanly. ([#49130](https://github.com/expo/expo/pull/49130) by [@gwdp](https://github.com/gwdp)) - [Android] Apply `reactNativeArchitectures` as a CMake ABI filter so single-ABI builds no longer compile the native code for unused ABIs. ([#49299](https://github.com/expo/expo/pull/49299) by [@alanjhughes](https://github.com/alanjhughes)) - [Android] Keep the Room-generated `UpdatesDatabase_Impl` constructor, so minified release builds no longer crash with `NoSuchMethodException` on first database access. ([#47729](https://github.com/expo/expo/pull/47729) by [@gabrieldonadel](https://github.com/gabrieldonadel)) +- [iOS] Propagate database failures from `addNewAssets` instead of swallowing them, which let an update be marked ready without its launch asset and then fail every launch. ([#49456](https://github.com/expo/expo/pull/49456) by [@alanjhughes](https://github.com/alanjhughes)) +- [iOS] Repair ready updates that are missing their launch asset by demoting them to pending during launcher selection, so a corrupted row is skipped and retried instead of failing every launch. ([#49457](https://github.com/expo/expo/pull/49457) by [@alanjhughes](https://github.com/alanjhughes)) +- [iOS] Register a downloaded update's assets, links, and ready status in a single transaction, so an interrupted or partially failed registration leaves no partial state behind. ([#49458](https://github.com/expo/expo/pull/49458) by [@alanjhughes](https://github.com/alanjhughes)) +- [iOS] Report a specific launch asset not found error when a launchable update has no linked launch asset, instead of failing silently or, for an update with no assets at all, hanging on the splash screen. ([#49459](https://github.com/expo/expo/pull/49459) by [@alanjhughes](https://github.com/alanjhughes)) +- [iOS] Preserve the cached update's launch failure as the emergency launch reason when the remote check finds no new update, instead of replacing it with the generic AppLoaderTask error. ([#49460](https://github.com/expo/expo/pull/49460) by [@alanjhughes](https://github.com/alanjhughes)) ### 💡 Others diff --git a/packages/expo-updates/ios/EXUpdates/AppLauncher/AppLauncherWithDatabase.swift b/packages/expo-updates/ios/EXUpdates/AppLauncher/AppLauncherWithDatabase.swift index ca27f03d64b9ae..80504be05fef10 100644 --- a/packages/expo-updates/ios/EXUpdates/AppLauncher/AppLauncherWithDatabase.swift +++ b/packages/expo-updates/ios/EXUpdates/AppLauncher/AppLauncherWithDatabase.swift @@ -193,7 +193,11 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher { ) completionQueue.async { - self.completion!(self.launchAssetError, self.launchAssetUrl != nil) + var error = self.launchAssetError + if error == nil && self.launchAssetUrl == nil { + error = UpdatesError.appLauncherLaunchAssetNotFound(updateId: launchedUpdate.updateId) + } + self.completion!(error, self.launchAssetUrl != nil) self.completion = nil } return @@ -211,6 +215,16 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher { self.assetFilesMap = UpdatesUtils.embeddedAssetsMap(withConfig: config, database: database, logger: logger) let assets = launchedUpdate.assets()! + if assets.isEmpty { + // a ready update with no assets cannot launch, and an empty loop below would never + // invoke the completion + completionQueue.async { + self.completion!(UpdatesError.appLauncherLaunchAssetNotFound(updateId: launchedUpdate.updateId), false) + self.completion = nil + } + return + } + let totalAssetCount = assets.count for asset in assets { let assetLocalUrl = directory.appendingPathComponent(asset.filename) @@ -230,7 +244,12 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher { if self.completedAssets == totalAssetCount { self.completionQueue.async { - self.completion!(self.launchAssetError, self.launchAssetUrl != nil) + var error = self.launchAssetError + if error == nil && self.launchAssetUrl == nil { + // completing without an error and without a launch asset would fail the launch silently + error = UpdatesError.appLauncherLaunchAssetNotFound(updateId: launchedUpdate.updateId) + } + self.completion!(error, self.launchAssetUrl != nil) self.completion = nil } } diff --git a/packages/expo-updates/ios/EXUpdates/AppLoader/AppLoader.swift b/packages/expo-updates/ios/EXUpdates/AppLoader/AppLoader.swift index 17e9e4e55c3fc8..a6abdcc87d2b14 100644 --- a/packages/expo-updates/ios/EXUpdates/AppLoader/AppLoader.swift +++ b/packages/expo-updates/ios/EXUpdates/AppLoader/AppLoader.swift @@ -358,18 +358,29 @@ open class AppLoader: NSObject { database.databaseQueue.async { self.arrayLock.lock() + guard let updateResponse = self.updateResponseContainingManifest, + let updateManifest = updateResponse.manifestUpdateResponsePart?.updateManifest else { + self.arrayLock.unlock() + self.finish(withError: UpdatesError.appLoaderFinishedWithoutManifest) + return + } + + var assetsToLink: [UpdateAsset] = [] for existingAsset in self.existingAssets { - var existingAssetFound: Bool = false + let existingAssetFound: Bool do { - existingAssetFound = try self.database.addExistingAsset( - existingAsset, - toUpdateWithId: self.updateResponseContainingManifest!.manifestUpdateResponsePart!.updateManifest.updateId - ) + existingAssetFound = try self.database.asset(withKey: existingAsset.key) != nil } catch { - self.logger.warn(message: "Error searching for existing asset in DB: \(error.localizedDescription)") + // treating a failed check as "not found" would re-insert an existing asset, and the + // key-conflicting replace cascade-deletes every update that uses the old row + self.arrayLock.unlock() + self.finish(withError: UpdatesError.appLoaderUnknownError(cause: error)) + return } - if !existingAssetFound { + if existingAssetFound { + assetsToLink.append(existingAsset) + } else { // the database and filesystem have gotten out of sync // do our best to create a new entry for this file even though it already existed on disk // TODO: we should probably get rid of this assumption that if an asset exists on disk with the same filename, it's the same asset @@ -392,9 +403,11 @@ open class AppLoader: NSObject { } do { - try self.database.addNewAssets( - self.finishedAssets, - toUpdateWithId: self.updateResponseContainingManifest!.manifestUpdateResponsePart!.updateManifest.updateId + try self.database.finishUpdateRegistration( + updateManifest, + newAssets: self.finishedAssets, + existingAssets: assetsToLink, + markFinished: self.erroredAssets.isEmpty ) } catch { self.arrayLock.unlock() @@ -402,17 +415,6 @@ open class AppLoader: NSObject { return } - if self.erroredAssets.isEmpty { - do { - let updateManifest = self.updateResponseContainingManifest!.manifestUpdateResponsePart!.updateManifest - try self.database.markUpdateFinished(updateManifest) - } catch { - self.arrayLock.unlock() - self.finish(withError: UpdatesError.appLoaderUnknownError(cause: error)) - return - } - } - var successBlock: AppLoaderSuccessBlock? var errorBlock: AppLoaderErrorBlock? @@ -432,7 +434,7 @@ open class AppLoader: NSObject { if let errorBlock = errorBlock { errorBlock(UpdatesError.appLoaderFailedToLoadAllAssets) } else if let successBlock = successBlock { - successBlock(self.updateResponseContainingManifest!) + successBlock(updateResponse) } } } diff --git a/packages/expo-updates/ios/EXUpdates/AppLoader/AppLoaderTask.swift b/packages/expo-updates/ios/EXUpdates/AppLoader/AppLoaderTask.swift index 7a1f4afda34782..65bb44b5b55a7a 100644 --- a/packages/expo-updates/ios/EXUpdates/AppLoader/AppLoaderTask.swift +++ b/packages/expo-updates/ios/EXUpdates/AppLoader/AppLoaderTask.swift @@ -104,7 +104,7 @@ public enum BackgroundUpdateStatus: Int { */ @objc(EXUpdatesAppLoaderTask) @objcMembers -public final class AppLoaderTask: NSObject { +public class AppLoaderTask: NSObject { public weak var delegate: AppLoaderTaskDelegate? public weak var swiftDelegate: AppLoaderTaskSwiftDelegate? @@ -126,6 +126,7 @@ public final class AppLoaderTask: NSObject { private var isTimerFinished: Bool private var hasLaunched: Bool private var isUpToDate: Bool + private var fallbackLaunchError: UpdatesError = .appLoaderTaskUnexpectedErrorDuringLaunch private let loaderTaskQueue: DispatchQueue public required init( @@ -166,10 +167,13 @@ public final class AppLoaderTask: NSObject { loadEmbeddedUpdate { self.launch { error, success in if !success { + // keep the cause so a launch that finishes without a specific error reports + // this failure instead of the generic error + let cause = UpdatesError.appLoaderTaskFailedToLaunch(cause: error) + self.fallbackLaunchError = cause if !shouldCheckForUpdate { self.finish(withError: error) } - let cause = UpdatesError.appLoaderTaskFailedToLaunch(cause: error) self.logger.error( cause: cause, code: .updateFailedToLoad @@ -223,7 +227,7 @@ public final class AppLoaderTask: NSObject { } else { delegate.appLoaderTask( self, - didFinishWithError: error ?? UpdatesError.appLoaderTaskUnexpectedErrorDuringLaunch + didFinishWithError: error ?? self.fallbackLaunchError ) } } @@ -268,7 +272,7 @@ public final class AppLoaderTask: NSObject { } } - private func loadEmbeddedUpdate(withCompletion completion: @escaping () -> Void) { + func loadEmbeddedUpdate(withCompletion completion: @escaping () -> Void) { AppLauncherWithDatabase.launchableUpdate( withConfig: config, database: database, @@ -325,13 +329,13 @@ public final class AppLoaderTask: NSObject { } } - private func launch(withCompletion completion: @escaping (_ error: UpdatesError?, _ success: Bool) -> Void) { + func launch(withCompletion completion: @escaping (_ error: UpdatesError?, _ success: Bool) -> Void) { let launcher = AppLauncherWithDatabase(config: config, database: database, directory: directory, completionQueue: loaderTaskQueue, logger: self.logger) candidateLauncher = launcher launcher.launchUpdate(withSelectionPolicy: selectionPolicy, completion: completion) } - private func loadRemoteUpdate(withCompletion completion: @escaping (_ remoteError: UpdatesError?, _ updateResponse: UpdateResponse?) -> Void) { + func loadRemoteUpdate(withCompletion completion: @escaping (_ remoteError: UpdatesError?, _ updateResponse: UpdateResponse?) -> Void) { remoteAppLoader = RemoteAppLoader( config: config, logger: logger, diff --git a/packages/expo-updates/ios/EXUpdates/Database/UpdatesDatabase.swift b/packages/expo-updates/ios/EXUpdates/Database/UpdatesDatabase.swift index 71b8a0eae71b07..bd1ff94236d26d 100644 --- a/packages/expo-updates/ios/EXUpdates/Database/UpdatesDatabase.swift +++ b/packages/expo-updates/ios/EXUpdates/Database/UpdatesDatabase.swift @@ -23,6 +23,9 @@ internal enum UpdatesDatabaseError: Error, Sendable, LocalizedError { case deleteUpdatesError(cause: Error) case deleteUnusedAssetsError(cause: Error) case setJsonDataError(cause: Error) + case transactionBeginError(resultCode: Int32) + case transactionCommitError(resultCode: Int32) + case finishedUpdateMissingLaunchAsset var errorDescription: String? { switch self { @@ -38,6 +41,12 @@ internal enum UpdatesDatabaseError: Error, Sendable, LocalizedError { return "Database error while deleting unused assets: \(cause.localizedDescription)" case let .setJsonDataError(cause): return "Database error while setting JSON data: \(cause.localizedDescription)" + case let .transactionBeginError(resultCode): + return "Database error while starting a transaction. SQLite result code: \(resultCode)" + case let .transactionCommitError(resultCode): + return "Database error while committing a transaction. SQLite result code: \(resultCode)" + case .finishedUpdateMissingLaunchAsset: + return "The update finished registration without a launch asset. Refusing to mark it as ready." } } } @@ -106,6 +115,24 @@ public final class UpdatesDatabase: NSObject { return try UpdatesDatabaseUtils.execute(sql: sql, withArgs: args, onDatabase: db.require("Missing database handle")) } + private func withTransaction(_ block: () throws -> Void) throws { + let beginResult = sqlite3_exec(db, "BEGIN;", nil, nil, nil) + guard beginResult == SQLITE_OK else { + throw UpdatesDatabaseError.transactionBeginError(resultCode: beginResult) + } + do { + try block() + } catch { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + throw error + } + let commitResult = sqlite3_exec(db, "COMMIT;", nil, nil, nil) + guard commitResult == SQLITE_OK else { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + throw UpdatesDatabaseError.transactionCommitError(resultCode: commitResult) + } + } + public func executeForObjC(sql: String, withArgs args: [Any]?) throws -> [Any] { return try execute(sql: sql, withArgs: args) } @@ -134,98 +161,100 @@ public final class UpdatesDatabase: NSObject { } public func addNewAssets(_ assets: [UpdateAsset], toUpdateWithId updateId: UUID) throws { - sqlite3_exec(db, "BEGIN;", nil, nil, nil) + try withTransaction { + try addNewAssetsInternal(assets, toUpdateWithId: updateId) + } + } + private func addNewAssetsInternal(_ assets: [UpdateAsset], toUpdateWithId updateId: UUID) throws { let assetInsertSql = """ INSERT OR REPLACE INTO "assets" ("key", "url", "headers", "extra_request_headers", "type", "metadata", "download_time", "relative_path", "hash", "hash_type", "expected_hash", "marked_for_deletion") VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 0); """ for asset in assets { - do { - _ = try execute( - sql: assetInsertSql, - withArgs: [ - asset.key, - asset.url, - asset.headers, - asset.extraRequestHeaders, - asset.type, - asset.metadata, - asset.downloadTime.require("asset downloadTime should be nonnull"), - asset.filename, - asset.contentHash, - UpdatesDatabaseHashType.Sha1.rawValue, - asset.expectedHash - ] - ) - } catch { - sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) - return - } + _ = try execute( + sql: assetInsertSql, + withArgs: [ + asset.key, + asset.url, + asset.headers, + asset.extraRequestHeaders, + asset.type, + asset.metadata, + asset.downloadTime.require("asset downloadTime should be nonnull"), + asset.filename, + asset.contentHash, + UpdatesDatabaseHashType.Sha1.rawValue, + asset.expectedHash + ] + ) // statements must stay in precisely this order for last_insert_rowid() to work correctly if asset.isLaunchAsset { let updateSql = "UPDATE updates SET launch_asset_id = last_insert_rowid() WHERE id = ?1;" - do { - _ = try execute(sql: updateSql, withArgs: [updateId]) - } catch { - sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) - return - } + _ = try execute(sql: updateSql, withArgs: [updateId]) } let updateInsertSql = """ INSERT OR REPLACE INTO updates_assets ("update_id", "asset_id") VALUES (?1, last_insert_rowid()); """ - do { - _ = try execute(sql: updateInsertSql, withArgs: [updateId]) - } catch { - sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) - return - } + _ = try execute(sql: updateInsertSql, withArgs: [updateId]) } + } - sqlite3_exec(db, "COMMIT;", nil, nil, nil) + public func finishUpdateRegistration(_ update: Update, newAssets: [UpdateAsset], existingAssets: [UpdateAsset], markFinished: Bool) throws { + try withTransaction { + for asset in existingAssets { + _ = try addExistingAsset(asset, toUpdateWithId: update.updateId) + } + try addNewAssetsInternal(newAssets, toUpdateWithId: update.updateId) + if markFinished { + try markUpdateFinished(update) + + // a ready update that cannot launch must never be committed + if update.status == UpdateStatus.StatusReady { + let launchAssetRows = try execute( + sql: "SELECT 1 FROM updates WHERE id = ?1 AND launch_asset_id IS NOT NULL;", + withArgs: [update.updateId] + ) + if launchAssetRows.isEmpty { + throw UpdatesDatabaseError.finishedUpdateMissingLaunchAsset + } + } + } + } } - public func addExistingAsset(_ asset: UpdateAsset, toUpdateWithId updateId: UUID) throws -> Bool { + private func addExistingAsset(_ asset: UpdateAsset, toUpdateWithId updateId: UUID) throws -> Bool { guard let key = asset.key else { return false } - sqlite3_exec(db, "BEGIN;", nil, nil, nil) - let assetSelectSql = """ SELECT id FROM assets WHERE "key" = ?1 LIMIT 1; """ let rows = try execute(sql: assetSelectSql, withArgs: [key]) - if !rows.isEmpty { - let assetId: NSNumber = rows[0].requiredValue(forKey: "id") - let insertSql = """ - INSERT OR REPLACE INTO updates_assets ("update_id", "asset_id") VALUES (?1, ?2); - """ - do { - _ = try execute(sql: insertSql, withArgs: [updateId, assetId.intValue]) - } catch { - sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) - throw UpdatesDatabaseError.addExistingAssetInsertOrReplaceIntoError(cause: error) - } - - if asset.isLaunchAsset { - let updateSql = "UPDATE updates SET launch_asset_id = ?1 WHERE id = ?2;" - do { - _ = try execute(sql: updateSql, withArgs: [assetId.intValue, updateId]) - } catch { - sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) - throw UpdatesDatabaseError.addExistingAssetUpdateLaunchAssetError(cause: error) - } - } + if rows.isEmpty { + return false } - sqlite3_exec(db, "COMMIT;", nil, nil, nil) + let assetId: NSNumber = rows[0].requiredValue(forKey: "id") + let insertSql = """ + INSERT OR REPLACE INTO updates_assets ("update_id", "asset_id") VALUES (?1, ?2); + """ + do { + _ = try execute(sql: insertSql, withArgs: [updateId, assetId.intValue]) + } catch { + throw UpdatesDatabaseError.addExistingAssetInsertOrReplaceIntoError(cause: error) + } - if rows.isEmpty { - return false + if asset.isLaunchAsset { + let updateSql = "UPDATE updates SET launch_asset_id = ?1 WHERE id = ?2;" + do { + _ = try execute(sql: updateSql, withArgs: [assetId.intValue, updateId]) + } catch { + throw UpdatesDatabaseError.addExistingAssetUpdateLaunchAssetError(cause: error) + } } return true @@ -444,7 +473,23 @@ public final class UpdatesDatabase: NSObject { ) let rows = try execute(sql: sql, withArgs: [config.scopeKey]) - return rows.map { row in + + // A ready row with no launch asset is corrupt (e.g. from an interrupted registration) and + // would fail every launch. Demote it for the loader to retry instead of offering it. + var launchableRows: [[String: Any?]] = [] + for row in rows { + let status: NSNumber = row.requiredValue(forKey: "status") + let launchAssetId: NSNumber? = row.optionalValue(forKey: "launch_asset_id") + if status.intValue == UpdateStatus.StatusReady.rawValue && launchAssetId == nil { + let updateId: UUID = row.requiredValue(forKey: "id") + let demoteSql = "UPDATE updates SET status = ?1 WHERE id = ?2;" + _ = try execute(sql: demoteSql, withArgs: [UpdateStatus.StatusPending.rawValue, updateId]) + } else { + launchableRows.append(row) + } + } + + return launchableRows.map { row in update(withRow: row, config: config) } } diff --git a/packages/expo-updates/ios/EXUpdates/UpdatesError.swift b/packages/expo-updates/ios/EXUpdates/UpdatesError.swift index 7d54677198d256..45bd31aa00b001 100644 --- a/packages/expo-updates/ios/EXUpdates/UpdatesError.swift +++ b/packages/expo-updates/ios/EXUpdates/UpdatesError.swift @@ -30,6 +30,7 @@ public enum UpdatesError: Error, Sendable, LocalizedError { case remoteAppLoaderHeaderDataError(cause: Error) case remoteAppLoaderUnknownError(cause: Error) case appLoaderFailedToLoadAllAssets + case appLoaderFinishedWithoutManifest case appLoaderUnknownError(cause: Error) case appLoaderTaskFailedToLaunch(cause: Error?) case appLoaderTaskUnexpectedErrorDuringLaunch @@ -42,6 +43,7 @@ public enum UpdatesError: Error, Sendable, LocalizedError { case appLauncherWithDatabaseAssetCopyFailed case appLauncherWithDatabaseUnknownError(cause: Error) case appLauncherNoLaunchableUpdates(cause: Error?) + case appLauncherLaunchAssetNotFound(updateId: UUID) case embeddedAppLoaderEmbeddedManifestLoadFailed case startupProcedureDidFinishWithError(cause: Error) case startupProcedureDidFinishBackgroundUpdateWithStatusWithError(cause: Error) @@ -99,6 +101,8 @@ public enum UpdatesError: Error, Sendable, LocalizedError { return "Error persisting header data to disk: \(cause.localizedDescription)" case .appLoaderFailedToLoadAllAssets: return "Failed to load all assets" + case .appLoaderFinishedWithoutManifest: + return "AppLoader finished without a processed update manifest. This is an internal error." case let .remoteAppLoaderUnknownError(cause): return "Unknown error: \(cause.localizedDescription)" case let .appLoaderUnknownError(cause): @@ -125,6 +129,8 @@ public enum UpdatesError: Error, Sendable, LocalizedError { return "Unknown error: \(cause.localizedDescription)" case let .appLauncherNoLaunchableUpdates(cause): return "No launchable updates found in database: \(cause?.localizedDescription ?? "Unknown error")" + case let .appLauncherLaunchAssetNotFound(updateId): + return "Launch asset not found for update \(updateId). The update cannot launch and will be repaired at the next app launch." case .embeddedAppLoaderEmbeddedManifestLoadFailed: return "Failed to load embedded manifest. Make sure you have configured expo-updates correctly." case let .startupProcedureDidFinishWithError(cause): diff --git a/packages/expo-updates/ios/Tests/AppLauncherWithDatabaseTests.swift b/packages/expo-updates/ios/Tests/AppLauncherWithDatabaseTests.swift index 1be6139f464795..beba20c1626846 100644 --- a/packages/expo-updates/ios/Tests/AppLauncherWithDatabaseTests.swift +++ b/packages/expo-updates/ios/Tests/AppLauncherWithDatabaseTests.swift @@ -39,6 +39,15 @@ class AppLauncherWithDatabaseMock: AppLauncherWithDatabase { } } +// Overrides only the update selection so ensureAllAssetsExist runs for real. +class AppLauncherRealAssetsMock: AppLauncherWithDatabase { + static var updateToLaunch: Update? + + override func launchableUpdate(selectionPolicy: SelectionPolicy, completion: @escaping AppLauncherUpdateCompletionBlock) { + completion(nil, AppLauncherRealAssetsMock.updateToLaunch) + } +} + @Suite("AppLauncherWithDatabase", .serialized) @MainActor class AppLauncherWithDatabaseTests { @@ -116,4 +125,150 @@ class AppLauncherWithDatabaseTests { #expect(sameUpdate!.lastAccessed >= beforeLaunch) } } + + // MARK: - missing launch asset + + private func makeConfig() throws -> UpdatesConfig { + try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigScopeKeyKey: "dummyScope", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + UpdatesConfig.EXUpdatesConfigHasEmbeddedUpdateKey: false, + ]) + } + + private func makeUpdate(config: UpdatesConfig, status: UpdateStatus = .StatusPending) -> Update { + Update( + manifest: ManifestFactory.manifest(forManifestJSON: [:]), + config: config, + database: db, + updateId: UUID(), + scopeKey: "dummyScope", + commitTime: Date(timeIntervalSince1970: 1608667851), + runtimeVersion: "1", + keep: true, + status: status, + isDevelopmentMode: false, + assetsFromManifest: nil, + url: URL(string: "https://example.com"), + requestHeaders: [:] + ) + } + + // resolves nil if the launcher never calls its completion within the timeout + private func launchOutcome(launcher: AppLauncherWithDatabase, config: UpdatesConfig) async -> (error: UpdatesError?, success: Bool)? { + final class Once: @unchecked Sendable { + private var done = false + private let lock = NSLock() + func run(_ block: () -> Void) { + lock.lock() + defer { lock.unlock() } + if !done { + done = true + block() + } + } + } + + let once = Once() + return await withCheckedContinuation { continuation in + launcher.launchUpdate(withSelectionPolicy: SelectionPolicyFactory.filterAwarePolicy(withRuntimeVersion: "1", config: config)) { error, success in + once.run { continuation.resume(returning: (error, success)) } + } + DispatchQueue.global().asyncAfter(deadline: .now() + 10.0) { + once.run { continuation.resume(returning: nil) } + } + } + } + + @Test + func `fails with launch asset not found when the update has no assets`() async throws { + let config = try makeConfig() + let update = makeUpdate(config: config) + + db.databaseQueue.sync { + try! db.addUpdate(update, config: config) + try! db.markUpdateFinished(update) + } + + AppLauncherRealAssetsMock.updateToLaunch = update + let launcher = AppLauncherRealAssetsMock( + config: config, + database: db, + directory: testDatabaseDir, + completionQueue: DispatchQueue.global(qos: .default), + logger: UpdatesLogger() + ) + + let outcome = await launchOutcome(launcher: launcher, config: config) + + #expect(outcome != nil, "the launcher never invoked its completion") + #expect(outcome?.success == false) + if case .appLauncherLaunchAssetNotFound = outcome?.error { + } else { + Issue.record("Expected appLauncherLaunchAssetNotFound but got \(String(describing: outcome?.error))") + } + } + + @Test + func `fails with launch asset not found when the launch asset is not linked`() async throws { + let config = try makeConfig() + let update = makeUpdate(config: config) + + let imageAsset = UpdateAsset(key: "image-1", type: "png") + imageAsset.isLaunchAsset = false + imageAsset.downloadTime = Date() + imageAsset.contentHash = "imagehash" + imageAsset.filename = "image-1.png" + try Data("fake image".utf8).write(to: testDatabaseDir.appendingPathComponent("image-1.png")) + + db.databaseQueue.sync { + try! db.addUpdate(update, config: config) + try! db.addNewAssets([imageAsset], toUpdateWithId: update.updateId) + try! db.markUpdateFinished(update) + } + + AppLauncherRealAssetsMock.updateToLaunch = update + let launcher = AppLauncherRealAssetsMock( + config: config, + database: db, + directory: testDatabaseDir, + completionQueue: DispatchQueue.global(qos: .default), + logger: UpdatesLogger() + ) + + let outcome = await launchOutcome(launcher: launcher, config: config) + + #expect(outcome != nil, "the launcher never invoked its completion") + #expect(outcome?.success == false) + if case .appLauncherLaunchAssetNotFound = outcome?.error { + } else { + Issue.record("Expected appLauncherLaunchAssetNotFound but got \(String(describing: outcome?.error))") + } + } + + @Test + func `fails with launch asset not found when the embedded bundle is missing`() async throws { + let config = try makeConfig() + // the test bundle contains no embedded bundle resource, so the lookup returns nil + let update = makeUpdate(config: config, status: .StatusEmbedded) + + AppLauncherRealAssetsMock.updateToLaunch = update + let launcher = AppLauncherRealAssetsMock( + config: config, + database: db, + directory: testDatabaseDir, + completionQueue: DispatchQueue.global(qos: .default), + logger: UpdatesLogger() + ) + + let outcome = await launchOutcome(launcher: launcher, config: config) + + #expect(outcome != nil, "the launcher never invoked its completion") + #expect(outcome?.success == false) + if case .appLauncherLaunchAssetNotFound = outcome?.error { + } else { + Issue.record("Expected appLauncherLaunchAssetNotFound but got \(String(describing: outcome?.error))") + } + } } diff --git a/packages/expo-updates/ios/Tests/AppLoaderTaskTests.swift b/packages/expo-updates/ios/Tests/AppLoaderTaskTests.swift new file mode 100644 index 00000000000000..d40498ddd21ded --- /dev/null +++ b/packages/expo-updates/ios/Tests/AppLoaderTaskTests.swift @@ -0,0 +1,136 @@ +// Copyright (c) 2026 650 Industries, Inc. All rights reserved. + +import Testing +import Foundation + +@testable import EXUpdates + +// Simulates a cold start where the cached update cannot launch and the +// remote check finds nothing new, without touching the network. +private final class FailingLaunchLoaderTask: AppLoaderTask { + static let brokenUpdateId = UUID() + + override func loadEmbeddedUpdate(withCompletion completion: @escaping () -> Void) { + completion() + } + + override func launch(withCompletion completion: @escaping (_ error: UpdatesError?, _ success: Bool) -> Void) { + completion(UpdatesError.appLauncherLaunchAssetNotFound(updateId: Self.brokenUpdateId), false) + } + + override func loadRemoteUpdate(withCompletion completion: @escaping (_ remoteError: UpdatesError?, _ updateResponse: UpdateResponse?) -> Void) { + completion(nil, nil) + } +} + +private final class ErrorRecordingDelegate: AppLoaderTaskDelegate { + private let onError: (Error) -> Void + + init(onError: @escaping (Error) -> Void) { + self.onError = onError + } + + func appLoaderTask(_: AppLoaderTask, didLoadCachedUpdate update: Update) -> Bool { + true + } + func appLoaderTask(_: AppLoaderTask, didStartLoadingUpdate update: Update?) {} + func appLoaderTask(_: AppLoaderTask, didFinishWithLauncher launcher: AppLauncher, isUpToDate: Bool) {} + func appLoaderTask(_: AppLoaderTask, didFinishWithError error: Error) { + onError(error) + } + func appLoaderTask( + _: AppLoaderTask, + didFinishBackgroundUpdateWithStatus status: BackgroundUpdateStatus, + update: Update?, + error: Error? + ) {} + func appLoaderTaskDidFinishAllLoading(_: AppLoaderTask) {} +} + +@Suite("AppLoaderTask", .serialized) +class AppLoaderTaskTests { + var testDatabaseDir: URL + var db: UpdatesDatabase + + init() throws { + let applicationSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).last + testDatabaseDir = applicationSupportDir!.appendingPathComponent("AppLoaderTaskTests") + + try? FileManager.default.removeItem(atPath: testDatabaseDir.path) + + if !FileManager.default.fileExists(atPath: testDatabaseDir.path) { + try FileManager.default.createDirectory(atPath: testDatabaseDir.path, withIntermediateDirectories: true) + } + + db = UpdatesDatabase() + db.databaseQueue.sync { + try! db.openDatabase(inDirectory: testDatabaseDir, logger: UpdatesLogger()) + } + } + + deinit { + db.databaseQueue.sync { + db.closeDatabase() + } + try? FileManager.default.removeItem(atPath: testDatabaseDir.path) + } + + @Test + func `reports the launcher failure instead of the generic error`() async throws { + let config = try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com", + UpdatesConfig.EXUpdatesConfigScopeKeyKey: "dummyScope", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + UpdatesConfig.EXUpdatesConfigHasEmbeddedUpdateKey: false, + ]) + + let task = FailingLaunchLoaderTask( + withConfig: config, + database: db, + directory: testDatabaseDir, + selectionPolicy: SelectionPolicyFactory.filterAwarePolicy(withRuntimeVersion: "1", config: config), + delegateQueue: DispatchQueue(label: "AppLoaderTaskTests.delegate"), + logger: UpdatesLogger() + ) + + final class Once: @unchecked Sendable { + private var done = false + private let lock = NSLock() + func run(_ block: () -> Void) { + lock.lock() + defer { lock.unlock() } + if !done { + done = true + block() + } + } + } + + let once = Once() + var strongDelegate: ErrorRecordingDelegate? + let receivedError: Error? = await withCheckedContinuation { continuation in + let delegate = ErrorRecordingDelegate { error in + once.run { continuation.resume(returning: error) } + } + strongDelegate = delegate + task.delegate = delegate + task.start() + DispatchQueue.global().asyncAfter(deadline: .now() + 10.0) { + once.run { continuation.resume(returning: nil) } + } + } + withExtendedLifetime(strongDelegate) {} + + #expect(receivedError != nil, "the loader task never reported an error") + + guard let updatesError = receivedError as? UpdatesError else { + Issue.record("Expected an UpdatesError but got \(String(describing: receivedError))") + return + } + + guard case .appLoaderTaskFailedToLaunch = updatesError else { + Issue.record("Expected appLoaderTaskFailedToLaunch but got \(updatesError)") + return + } + } +} diff --git a/packages/expo-updates/ios/Tests/UpdatesDatabaseTests.swift b/packages/expo-updates/ios/Tests/UpdatesDatabaseTests.swift index 7fe4b14aba8eb8..9608a326a98560 100644 --- a/packages/expo-updates/ios/Tests/UpdatesDatabaseTests.swift +++ b/packages/expo-updates/ios/Tests/UpdatesDatabaseTests.swift @@ -115,6 +115,350 @@ class UpdatesDatabaseTests { } } + // MARK: - addNewAssets failure handling + + @Suite("addNewAssets failure handling", .serialized) + struct AddNewAssetsFailureTests { + var testDatabaseDir: URL + var db: UpdatesDatabase + var manifest: ExpoUpdatesManifest + var config: UpdatesConfig + + init() throws { + let applicationSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).last + testDatabaseDir = applicationSupportDir!.appendingPathComponent("AddNewAssetsFailureTests") + + try? FileManager.default.removeItem(atPath: testDatabaseDir.path) + + if !FileManager.default.fileExists(atPath: testDatabaseDir.path) { + try FileManager.default.createDirectory(atPath: testDatabaseDir.path, withIntermediateDirectories: true) + } + + db = UpdatesDatabase() + + 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"] + ]) + + config = try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://exp.host/@test/test", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]) + + db.databaseQueue.sync { + try! db.openDatabase(inDirectory: testDatabaseDir, logger: UpdatesLogger()) + } + } + + @Test + func `throws and rolls back when a statement fails`() throws { + let update = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: manifest, + extensions: [:], + config: config, + database: db + ) + + let asset = UpdateAsset(key: "bundle-key", type: "js") + asset.downloadTime = Date() + asset.contentHash = "hash" + asset.filename = "bundle.js" + asset.isLaunchAsset = true + + db.databaseQueue.sync { + try! db.addUpdate(update, config: config) + + // force the per-asset join insert to fail + _ = try! db.execute(sql: "DROP TABLE updates_assets", withArgs: nil) + + do { + try db.addNewAssets([asset], toUpdateWithId: update.updateId) + Issue.record("Expected addNewAssets to throw when a statement fails") + } catch {} + + // the whole batch must have been rolled back + #expect(try! db.asset(withKey: "bundle-key") == nil) + } + } + + @Test + func `throws when a transaction is already open`() throws { + let update = ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: manifest, + extensions: [:], + config: config, + database: db + ) + + let asset = UpdateAsset(key: "bundle-key", type: "js") + asset.downloadTime = Date() + asset.contentHash = "hash" + asset.filename = "bundle.js" + asset.isLaunchAsset = true + + db.databaseQueue.sync { + try! db.addUpdate(update, config: config) + + // an already-open transaction makes the internal BEGIN fail + _ = try! db.execute(sql: "BEGIN;", withArgs: nil) + defer { _ = try? db.execute(sql: "ROLLBACK;", withArgs: nil) } + + do { + try db.addNewAssets([asset], toUpdateWithId: update.updateId) + Issue.record("Expected addNewAssets to throw when its transaction cannot start") + } catch UpdatesDatabaseError.transactionBeginError { + } catch { + Issue.record("Expected transactionBeginError but got \(error)") + } + } + } + } + + // MARK: - repair updates missing launch asset + + @Suite("repair updates missing launch asset", .serialized) + struct RepairMissingLaunchAssetTests { + var testDatabaseDir: URL + var db: UpdatesDatabase + var config: UpdatesConfig + + init() throws { + let applicationSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).last + testDatabaseDir = applicationSupportDir!.appendingPathComponent("RepairMissingLaunchAssetTests") + + try? FileManager.default.removeItem(atPath: testDatabaseDir.path) + + if !FileManager.default.fileExists(atPath: testDatabaseDir.path) { + try FileManager.default.createDirectory(atPath: testDatabaseDir.path, withIntermediateDirectories: true) + } + + db = UpdatesDatabase() + + config = try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://exp.host/@test/test", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]) + + db.databaseQueue.sync { + try! db.openDatabase(inDirectory: testDatabaseDir, logger: UpdatesLogger()) + } + } + + private func makeUpdate(id: String) -> Update { + let manifest = ExpoUpdatesManifest(rawManifestJSON: [ + "runtimeVersion": "1", + "id": id, + "createdAt": "2020-11-11T00:17:54.797Z", + "launchAsset": ["url": "https://url.to/bundle.js", "contentType": "application/javascript"] + ]) + return ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: manifest, + extensions: [:], + config: config, + database: db + ) + } + + @Test + func `demotes ready updates without a launch asset during selection`() throws { + let update = makeUpdate(id: "0eef8214-4833-4089-9dff-b4138a14f196") + + db.databaseQueue.sync { + try! db.addUpdate(update, config: config) + // simulate the broken end state: finished without any assets linked + try! db.markUpdateFinished(update) + + let launchable = try! db.launchableUpdates(withConfig: config) + #expect(launchable.isEmpty) + + let reloaded = try! db.update(withId: update.updateId, config: config) + #expect(reloaded?.status == .StatusPending) + } + } + + @Test + func `returns ready updates with a launch asset untouched`() throws { + let update = makeUpdate(id: "0eef8214-4833-4089-9dff-b4138a14f197") + + let launchAsset = UpdateAsset(key: "bundle-key", type: "js") + launchAsset.downloadTime = Date() + launchAsset.contentHash = "hash" + launchAsset.filename = "bundle.js" + launchAsset.isLaunchAsset = true + + db.databaseQueue.sync { + try! db.addUpdate(update, config: config) + try! db.addNewAssets([launchAsset], toUpdateWithId: update.updateId) + try! db.markUpdateFinished(update) + + let launchable = try! db.launchableUpdates(withConfig: config) + #expect(launchable.map(\.updateId) == [update.updateId]) + + let reloaded = try! db.update(withId: update.updateId, config: config) + #expect(reloaded?.status == .StatusReady) + } + } + } + + // MARK: - finishUpdateRegistration + + @Suite("finishUpdateRegistration", .serialized) + struct FinishUpdateRegistrationTests { + var testDatabaseDir: URL + var db: UpdatesDatabase + var config: UpdatesConfig + + init() throws { + let applicationSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).last + testDatabaseDir = applicationSupportDir!.appendingPathComponent("FinishUpdateRegistrationTests") + + try? FileManager.default.removeItem(atPath: testDatabaseDir.path) + + if !FileManager.default.fileExists(atPath: testDatabaseDir.path) { + try FileManager.default.createDirectory(atPath: testDatabaseDir.path, withIntermediateDirectories: true) + } + + db = UpdatesDatabase() + + config = try UpdatesConfig.config(fromDictionary: [ + UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://exp.host/@test/test", + UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1", + ]) + + db.databaseQueue.sync { + try! db.openDatabase(inDirectory: testDatabaseDir, logger: UpdatesLogger()) + } + } + + private func makeUpdate(id: String, createdAt: String) -> Update { + let manifest = ExpoUpdatesManifest(rawManifestJSON: [ + "runtimeVersion": "1", + "id": id, + "createdAt": createdAt, + "launchAsset": ["url": "https://url.to/bundle.js", "contentType": "application/javascript"] + ]) + return ExpoUpdatesUpdate.update( + withExpoUpdatesManifest: manifest, + extensions: [:], + config: config, + database: db + ) + } + + private func makeAsset(key: String, isLaunchAsset: Bool = false) -> UpdateAsset { + let asset = UpdateAsset(key: key, type: "js") + asset.downloadTime = Date() + asset.contentHash = key + asset.filename = "\(key).js" + asset.isLaunchAsset = isLaunchAsset + return asset + } + + private func joinRowCount(forUpdateId updateId: UUID) -> Int { + db.databaseQueue.sync { + try! db.execute(sql: "SELECT asset_id FROM updates_assets WHERE update_id = ?1;", withArgs: [updateId]).count + } + } + + @Test + func `registers assets and marks the update finished`() throws { + let update1 = makeUpdate(id: "0eef8214-4833-4089-9dff-b4138a14f196", createdAt: "2020-11-11T00:17:54.797Z") + let update2 = makeUpdate(id: "0eef8214-4833-4089-9dff-b4138a14f197", createdAt: "2020-11-11T00:17:55.797Z") + + db.databaseQueue.sync { + try! db.addUpdate(update1, config: config) + try! db.addNewAssets([makeAsset(key: "asset-a")], toUpdateWithId: update1.updateId) + + try! db.addUpdate(update2, config: config) + try! db.finishUpdateRegistration( + update2, + newAssets: [makeAsset(key: "asset-b", isLaunchAsset: true)], + existingAssets: [makeAsset(key: "asset-a")], + markFinished: true + ) + } + + db.databaseQueue.sync { + let reloaded = try! db.update(withId: update2.updateId, config: config) + #expect(reloaded?.status == .StatusReady) + } + #expect(joinRowCount(forUpdateId: update2.updateId) == 2) + } + + @Test + func `persists nothing when any statement fails`() throws { + let update1 = makeUpdate(id: "0eef8214-4833-4089-9dff-b4138a14f198", createdAt: "2020-11-11T00:17:56.797Z") + let update2 = makeUpdate(id: "0eef8214-4833-4089-9dff-b4138a14f199", createdAt: "2020-11-11T00:17:57.797Z") + + db.databaseQueue.sync { + try! db.addUpdate(update1, config: config) + try! db.addNewAssets([makeAsset(key: "asset-a")], toUpdateWithId: update1.updateId) + + try! db.addUpdate(update2, config: config) + + // fail the insert of the new asset, after the existing asset was linked + _ = try! db.execute( + sql: """ + CREATE TRIGGER fail_asset_b BEFORE INSERT ON assets WHEN NEW."key" = 'asset-b' + BEGIN SELECT RAISE(ABORT, 'injected failure'); END; + """, + withArgs: nil + ) + + do { + try db.finishUpdateRegistration( + update2, + newAssets: [makeAsset(key: "asset-b", isLaunchAsset: true)], + existingAssets: [makeAsset(key: "asset-a")], + markFinished: true + ) + Issue.record("Expected finishUpdateRegistration to throw when a statement fails") + } catch let error as UpdatesDatabaseUtilsError { + #expect(error.info?.message.contains("injected failure") == true) + } catch { + Issue.record("Expected the injected statement failure but got \(error)") + } + } + + db.databaseQueue.sync { + let reloaded = try! db.update(withId: update2.updateId, config: config) + #expect(reloaded?.status == .StatusPending) + } + #expect(joinRowCount(forUpdateId: update2.updateId) == 0) + } + + @Test + func `throws when marking finished without a launch asset`() throws { + let update = makeUpdate(id: "0eef8214-4833-4089-9dff-b4138a14f19a", createdAt: "2020-11-11T00:17:58.797Z") + + db.databaseQueue.sync { + try! db.addUpdate(update, config: config) + + do { + try db.finishUpdateRegistration( + update, + newAssets: [makeAsset(key: "image-a")], + existingAssets: [], + markFinished: true + ) + Issue.record("Expected finishUpdateRegistration to throw when no launch asset was linked") + } catch UpdatesDatabaseError.finishedUpdateMissingLaunchAsset { + } catch { + Issue.record("Expected finishedUpdateMissingLaunchAsset but got \(error)") + } + } + + db.databaseQueue.sync { + let reloaded = try! db.update(withId: update.updateId, config: config) + #expect(reloaded?.status == .StatusPending) + } + #expect(joinRowCount(forUpdateId: update.updateId) == 0) + } + } + // MARK: - setExtraClientParams @Suite("setExtraClientParams", .serialized) diff --git a/packages/expo-updates/utils/src/__tests__/createManifestForBuildAsync-test.ts b/packages/expo-updates/utils/src/__tests__/createManifestForBuildAsync-test.ts new file mode 100644 index 00000000000000..d6ba1e8b424571 --- /dev/null +++ b/packages/expo-updates/utils/src/__tests__/createManifestForBuildAsync-test.ts @@ -0,0 +1,74 @@ +import { + createMetroServerAndBundleRequestAsync, + exportEmbedAssetsAsync, +} from 'expo/internal/unstable-expo-updates-cli-exports'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { createManifestForBuildAsync } from '../createManifestForBuildAsync'; + +jest.mock('expo/config/paths', () => ({ + resolveEntryPoint: () => 'index.js', +})); +jest.mock('expo/internal/unstable-expo-updates-cli-exports', () => ({ + drawableFileTypes: new Set(['png']), + createMetroServerAndBundleRequestAsync: jest.fn(), + exportEmbedAssetsAsync: jest.fn(), +})); + +// An asset shipping scale variants outside the set iOS allows, as `react-native-ui-lib` icons do. +const assetWithNonIosScales = { + name: 'checkSmall', + type: 'png', + httpServerLocation: '/assets/icons', + width: 16, + height: 16, + scales: [1, 1.5, 2, 3, 4], + fileHashes: ['hash-1x', 'hash-1.5x', 'hash-2x', 'hash-3x', 'hash-4x'], +}; + +let cwd: string; +let projectRoot: string; + +beforeEach(() => { + cwd = process.cwd(); + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'create-manifest-')); + jest.mocked(createMetroServerAndBundleRequestAsync).mockResolvedValue({ + server: { end: jest.fn() }, + bundleRequest: {}, + } as any); + jest.mocked(exportEmbedAssetsAsync).mockResolvedValue([assetWithNonIosScales] as any); +}); + +afterEach(() => { + process.chdir(cwd); + fs.rmSync(projectRoot, { recursive: true, force: true }); +}); + +async function createManifestAsync(platform: 'ios' | 'android') { + await createManifestForBuildAsync(platform, projectRoot, projectRoot); + return JSON.parse(fs.readFileSync(path.join(projectRoot, 'app.manifest'), 'utf8')); +} + +describe(createManifestForBuildAsync, () => { + it('assigns each iOS scale the hash of its own file when non-iOS scales are filtered out', async () => { + const manifest = await createManifestAsync('ios'); + expect(manifest.assets.map((asset: any) => [asset.scale, asset.packagerHash])).toEqual([ + [1, 'hash-1x'], + [2, 'hash-2x'], + [3, 'hash-3x'], + ]); + }); + + it('assigns each Android scale the hash of its own file', async () => { + const manifest = await createManifestAsync('android'); + expect(manifest.assets.map((asset: any) => [asset.scale, asset.packagerHash])).toEqual([ + [1, 'hash-1x'], + [1.5, 'hash-1.5x'], + [2, 'hash-2x'], + [3, 'hash-3x'], + [4, 'hash-4x'], + ]); + }); +}); diff --git a/packages/expo-updates/utils/src/createManifestForBuildAsync.ts b/packages/expo-updates/utils/src/createManifestForBuildAsync.ts index d4e368a444d485..73aac3fc3c4876 100644 --- a/packages/expo-updates/utils/src/createManifestForBuildAsync.ts +++ b/packages/expo-updates/utils/src/createManifestForBuildAsync.ts @@ -64,12 +64,14 @@ export async function createManifestForBuildAsync( 'The hashAssetFiles Metro plugin is not configured. You need to add a metro.config.js to your project that configures Metro to use this plugin. See https://github.com/expo/expo/blob/main/packages/expo-updates/README.md#metroconfigjs for an example.' ); } - filterPlatformAssetScales(platform, asset.scales).forEach(function (scale, index) { + filterPlatformAssetScales(platform, asset.scales).forEach(function (scale) { const baseAssetInfoForManifest = { name: asset.name, type: asset.type, scale, - packagerHash: asset.fileHashes[index], + // `fileHashes` is parallel to the unfiltered `asset.scales`, so it must be indexed by the + // scale's position there rather than by its position in the filtered list. + packagerHash: asset.fileHashes[asset.scales.indexOf(scale)], subdirectory: asset.httpServerLocation, }; if (platform === 'ios') {