From 13dcf436dc8541c0538a1201f5bb9fd8ec3fc37b Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Thu, 5 Dec 2024 13:25:30 -0600 Subject: [PATCH] feat(shorebird_cli): iOS linking improvment (improved class table sort) (#2655) --- bin/internal/flutter.version | 2 +- .../lib/src/archive/directory_archive.dart | 5 +- .../lib/src/artifact_manager.dart | 20 +++ .../lib/src/code_push_client_wrapper.dart | 56 ++++++- .../lib/src/commands/patch/aar_patcher.dart | 1 + .../src/commands/patch/android_patcher.dart | 1 + .../commands/patch/ios_framework_patcher.dart | 58 ++++++- .../lib/src/commands/patch/ios_patcher.dart | 54 ++++++- .../lib/src/commands/patch/patch_command.dart | 44 ++++- .../lib/src/commands/patch/patcher.dart | 4 + .../release/ios_framework_releaser.dart | 1 + .../src/commands/release/ios_releaser.dart | 1 + .../test/src/artifact_manager_test.dart | 40 +++++ .../src/code_push_client_wrapper_test.dart | 150 ++++++++++++++---- .../patch/ios_framework_patcher_test.dart | 120 +++++++++++++- .../src/commands/patch/ios_patcher_test.dart | 111 +++++++++++-- .../commands/patch/patch_command_test.dart | 119 +++++++++++--- .../test/src/commands/patch/patcher_test.dart | 15 ++ .../release/ios_framework_releaser_test.dart | 2 + .../commands/release/ios_releaser_test.dart | 9 +- 20 files changed, 719 insertions(+), 94 deletions(-) diff --git a/bin/internal/flutter.version b/bin/internal/flutter.version index f521d4ea..eac4455e 100644 --- a/bin/internal/flutter.version +++ b/bin/internal/flutter.version @@ -1 +1 @@ -e8206cf2e76e3a4e0c8e062c20bb563cae39a1f1 +5a3ba347fd4e2ed6b17077af8ea2aca58bb26747 diff --git a/packages/shorebird_cli/lib/src/archive/directory_archive.dart b/packages/shorebird_cli/lib/src/archive/directory_archive.dart index 2e445ba7..a803e1b4 100644 --- a/packages/shorebird_cli/lib/src/archive/directory_archive.dart +++ b/packages/shorebird_cli/lib/src/archive/directory_archive.dart @@ -7,9 +7,10 @@ import 'package:path/path.dart' as p; /// A wrapper around a directory that can be zipped. extension DirectoryArchive on Directory { /// Copies this directory to a temporary directory and zips it. - Future zipToTempFile() async { + Future zipToTempFile({String? name}) async { final tempDir = await Directory.systemTemp.createTemp(); - final outFile = File(p.join(tempDir.path, '${p.basename(path)}.zip')); + final fileName = name ?? p.basename(path); + final outFile = File(p.join(tempDir.path, '$fileName.zip')); await Isolate.run(() { ZipFileEncoder().zipDirectory(this, filename: outFile.path); }); diff --git a/packages/shorebird_cli/lib/src/artifact_manager.dart b/packages/shorebird_cli/lib/src/artifact_manager.dart index 8bbb9327..a7b2b5ba 100644 --- a/packages/shorebird_cli/lib/src/artifact_manager.dart +++ b/packages/shorebird_cli/lib/src/artifact_manager.dart @@ -357,6 +357,26 @@ class ArtifactManager { return ipaFiles.single; } + /// Returns the path to the shorebird release supplement directory on iOS. + /// + /// Returns null if there is no supplement directory + /// (e.g. when using older Flutter revisions). + Directory? getIosReleaseSupplementDirectory() { + final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; + final releaseSupplementDir = Directory( + p.join(projectRoot.path, 'build', 'ios', 'shorebird'), + ); + + if (!releaseSupplementDir.existsSync()) { + logger.detail( + 'No iOS release supplements found at ${releaseSupplementDir.path}', + ); + return null; + } + + return releaseSupplementDir; + } + /// Name of the App.xcframework generated by `shorebird release ios-framework` static const String appXcframeworkName = 'App.xcframework'; diff --git a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart index bcf45b63..03d31032 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -616,7 +616,8 @@ aar artifact already exists, continuing...''', return thinnedArchiveDirectory; } - /// Uploads a release .xcarchive and .app to the Shorebird server. + /// Uploads a release .xcarchive, .app, and supplementary files to the + /// Shorebird server. Future createIosReleaseArtifacts({ required String appId, required int releaseId, @@ -624,6 +625,7 @@ aar artifact already exists, continuing...''', required String runnerPath, required bool isCodesigned, required String? podfileLockHash, + required String? supplementPath, }) async { final createArtifactProgress = logger.progress('Uploading artifacts'); final thinnedArchiveDirectory = @@ -668,14 +670,40 @@ aar artifact already exists, continuing...''', ); } + if (supplementPath != null) { + final zippedSupplement = await Directory(supplementPath).zipToTempFile( + name: 'ios_supplement', + ); + try { + await codePushClient.createReleaseArtifact( + appId: appId, + releaseId: releaseId, + artifactPath: zippedSupplement.path, + arch: 'ios_supplement', + platform: ReleasePlatform.ios, + hash: sha256.convert(await zippedSupplement.readAsBytes()).toString(), + canSideload: false, + podfileLockHash: podfileLockHash, + ); + } catch (error) { + _handleErrorAndExit( + error, + progress: createArtifactProgress, + message: 'Error uploading release supplements: $error', + ); + } + } + createArtifactProgress.complete(); } - /// Zips and uploads a release xcframework to the Shorebird server. + /// Zips and uploads a release xcframework and supplementary files to the + /// Shorebird server. Future createIosFrameworkReleaseArtifacts({ required String appId, required int releaseId, required String appFrameworkPath, + required String? supplementPath, }) async { final createArtifactProgress = logger.progress('Uploading artifacts'); final appFrameworkDirectory = Directory(appFrameworkPath); @@ -705,6 +733,30 @@ aar artifact already exists, continuing...''', ); } + if (supplementPath != null) { + final zippedSupplement = await Directory(supplementPath).zipToTempFile( + name: 'ios_framework_supplement', + ); + try { + await codePushClient.createReleaseArtifact( + appId: appId, + releaseId: releaseId, + artifactPath: zippedSupplement.path, + arch: 'ios_framework_supplement', + platform: ReleasePlatform.ios, + hash: sha256.convert(await zippedSupplement.readAsBytes()).toString(), + canSideload: false, + podfileLockHash: null, + ); + } catch (error) { + _handleErrorAndExit( + error, + progress: createArtifactProgress, + message: 'Error uploading release supplements: $error', + ); + } + } + createArtifactProgress.complete(); } diff --git a/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart index 12fe2d7e..6ea8983c 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart @@ -105,6 +105,7 @@ class AarPatcher extends Patcher { required String appId, required int releaseId, required File releaseArtifact, + File? supplementArtifact, }) async { final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts( appId: appId, diff --git a/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart index fbdbf8f3..015eb8f6 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart @@ -138,6 +138,7 @@ Looked in: required String appId, required int releaseId, required File releaseArtifact, + File? supplementArtifact, Duration downloadMessageTimeout = const Duration(minutes: 1), }) async { final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts( diff --git a/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart index ebe58321..44fd63c6 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart @@ -39,6 +39,9 @@ class IosFrameworkPatcher extends Patcher { required super.target, }); + String get _patchClassTableLinkInfoFile => + p.join(buildDirectory.path, 'ios', 'shorebird', 'App.ct.link'); + String get _vmcodeOutputPath => p.join(buildDirectory.path, 'out.vmcode'); String get _appDillCopyPath => p.join(buildDirectory.path, 'app.dill'); @@ -46,6 +49,9 @@ class IosFrameworkPatcher extends Patcher { @override String get primaryReleaseArtifactArch => 'xcframework'; + @override + String? get supplementaryReleaseArtifactArch => 'ios_framework_supplement'; + @override ReleaseType get releaseType => ReleaseType.iosFramework; @@ -145,17 +151,37 @@ class IosFrameworkPatcher extends Patcher { required String appId, required int releaseId, required File releaseArtifact, + File? supplementArtifact, }) async { final unzipProgress = logger.progress('Extracting release artifact'); - final tempDir = Directory.systemTemp.createTempSync(); - await artifactManager.extractZip( - zipFile: releaseArtifact, - outputDirectory: tempDir, - ); - final releaseXcframeworkPath = tempDir.path; + late final String releaseXcframeworkPath; + { + final tempDir = Directory.systemTemp.createTempSync(); + await artifactManager.extractZip( + zipFile: releaseArtifact, + outputDirectory: tempDir, + ); + releaseXcframeworkPath = tempDir.path; + } - unzipProgress - .complete('Extracted release artifact to $releaseXcframeworkPath'); + File? releaseClassTableLinkInfoFile; + if (supplementArtifact != null) { + final tempDir = Directory.systemTemp.createTempSync(); + await artifactManager.extractZip( + zipFile: supplementArtifact, + outputDirectory: tempDir, + ); + releaseClassTableLinkInfoFile = File(p.join(tempDir.path, 'App.ct.link')); + + if (!releaseClassTableLinkInfoFile.existsSync()) { + logger.err('Unable to find class table link info file'); + throw ProcessExit(ExitCode.software.code); + } + } + + unzipProgress.complete( + 'Extracted release artifact to $releaseXcframeworkPath', + ); final releaseArtifactFile = File( p.join( releaseXcframeworkPath, @@ -174,6 +200,22 @@ class IosFrameworkPatcher extends Patcher { ); final useLinker = AotTools.usesLinker(shorebirdEnv.flutterRevision); if (useLinker) { + // If we're using a newer version of the linker, we need to also copy the + // necessary class table link information alongside the snapshots. + if (releaseClassTableLinkInfoFile != null) { + // Copy the release's class table link info file next to the release + // snapshot so that it can be used to generate a patch. + releaseClassTableLinkInfoFile.copySync( + p.join(releaseArtifactFile.parent.path, 'App.ct.link'), + ); + + // Copy the patch's class table link info file to the build directory + // so that it can be used to generate a patch. + File(_patchClassTableLinkInfoFile).copySync( + p.join(buildDirectory.path, 'out.ct.link'), + ); + } + await _runLinker( aotSnapshot: aotSnapshotFile, releaseArtifact: releaseArtifactFile, diff --git a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart index 38815b0c..bed946ea 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart @@ -47,6 +47,9 @@ class IosPatcher extends Patcher { required super.target, }); + String get _patchClassTableLinkInfoPath => + p.join(buildDirectory.path, 'ios', 'shorebird', 'App.ct.link'); + String get _aotOutputPath => p.join(buildDirectory.path, 'out.aot'); String get _vmcodeOutputPath => p.join(buildDirectory.path, 'out.vmcode'); @@ -85,6 +88,9 @@ class IosPatcher extends Patcher { @override String get primaryReleaseArtifactArch => 'xcarchive'; + @override + String? get supplementaryReleaseArtifactArch => 'ios_supplement'; + @override Future assertPreconditions() async { try { @@ -229,6 +235,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', required String appId, required int releaseId, required File releaseArtifact, + File? supplementArtifact, }) async { // Verify that we have built a patch .xcarchive if (artifactManager.getXcarchiveDirectory()?.path == null) { @@ -237,12 +244,31 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', } final unzipProgress = logger.progress('Extracting release artifact'); - final tempDir = Directory.systemTemp.createTempSync(); - await artifactManager.extractZip( - zipFile: releaseArtifact, - outputDirectory: tempDir, - ); - final releaseXcarchivePath = tempDir.path; + + late final String releaseXcarchivePath; + { + final tempDir = Directory.systemTemp.createTempSync(); + await artifactManager.extractZip( + zipFile: releaseArtifact, + outputDirectory: tempDir, + ); + releaseXcarchivePath = tempDir.path; + } + + File? releaseClassTableLinkInfoFile; + if (supplementArtifact != null) { + final tempDir = Directory.systemTemp.createTempSync(); + await artifactManager.extractZip( + zipFile: supplementArtifact, + outputDirectory: tempDir, + ); + releaseClassTableLinkInfoFile = File(p.join(tempDir.path, 'App.ct.link')); + + if (!releaseClassTableLinkInfoFile.existsSync()) { + logger.err('Unable to find class table link info file'); + throw ProcessExit(ExitCode.software.code); + } + } unzipProgress.complete(); final appDirectory = artifactManager.getIosAppDirectory( @@ -263,6 +289,22 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', final useLinker = AotTools.usesLinker(shorebirdEnv.flutterRevision); if (useLinker) { + // If we're using a newer version of the linker, we need to also copy the + // necessary class table link information alongside the snapshots. + if (releaseClassTableLinkInfoFile != null) { + // Copy the release's class table link info file next to the release + // snapshot so that it can be used to generate a patch. + releaseClassTableLinkInfoFile.copySync( + p.join(releaseArtifactFile.parent.path, 'App.ct.link'), + ); + + // Copy the patch's class table link info file to the build directory + // so that it can be used to generate a patch. + File(_patchClassTableLinkInfoPath).copySync( + p.join(buildDirectory.path, 'out.ct.link'), + ); + } + final (:exitCode, :linkPercentage) = await _runLinker( releaseArtifact: releaseArtifactFile, kernelFile: File(_appDillCopyPath), diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart index c114fc27..b2a162dc 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -283,11 +283,24 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl platform: patcher.releaseType.releasePlatform, ); - final releaseArchive = await downloadPrimaryReleaseArtifact( + final supplementalArtifact = + patcher.supplementaryReleaseArtifactArch != null + ? await codePushClientWrapper.getOptionalReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: patcher.supplementaryReleaseArtifactArch!, + platform: patcher.releaseType.releasePlatform, + ) + : null; + + final releaseArchive = await downloadReleaseArtifact( releaseArtifact: releaseArtifact, - patcher: patcher, ); + final supplementArchive = supplementalArtifact != null + ? await downloadReleaseArtifact(releaseArtifact: supplementalArtifact) + : null; + final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith( flutterRevisionOverride: release.flutterRevision, ); @@ -313,6 +326,7 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl appId: appId, releaseId: release.id, releaseArtifact: releaseArchive, + supplementArtifact: supplementArchive, ); final dryRun = results['dry-run'] == true; @@ -478,15 +492,14 @@ ${summary.join('\n')} } } - Future downloadPrimaryReleaseArtifact({ + Future downloadReleaseArtifact({ required ReleaseArtifact releaseArtifact, - required Patcher patcher, }) async { final File artifactFile; try { artifactFile = await artifactManager.downloadWithProgressUpdates( Uri.parse(releaseArtifact.url), - message: 'Downloading ${patcher.primaryReleaseArtifactArch}', + message: 'Downloading ${releaseArtifact.arch}', ); } catch (_) { throw ProcessExit(ExitCode.software.code); @@ -495,3 +508,24 @@ ${summary.join('\n')} return artifactFile; } } + +extension on CodePushClientWrapper { + Future getOptionalReleaseArtifact({ + required String appId, + required int releaseId, + required String arch, + required ReleasePlatform platform, + }) async { + try { + final artifact = await getReleaseArtifact( + appId: appId, + releaseId: releaseId, + arch: arch, + platform: platform, + ); + return artifact; + } on CodePushNotFoundException catch (_) { + return null; + } + } +} diff --git a/packages/shorebird_cli/lib/src/commands/patch/patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/patcher.dart index 7611985f..830e460e 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patcher.dart @@ -62,6 +62,9 @@ ${iOSLinkPercentageUrl.toLink()} /// For example, 'aab' for Android, 'xcarchive' for iOS. String get primaryReleaseArtifactArch; + /// The identifier used for any supplementary release artifacts. + String? get supplementaryReleaseArtifactArch => null; + /// The root directory of the current project. Directory get projectRoot => shorebirdEnv.getShorebirdProjectRoot()!; @@ -91,6 +94,7 @@ ${iOSLinkPercentageUrl.toLink()} required String appId, required int releaseId, required File releaseArtifact, + File? supplementArtifact, }); /// Updates the provided metadata to include patcher-specific fields. diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart index 6f7c0516..6cd516e0 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart @@ -144,6 +144,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', appId: appId, releaseId: release.id, appFrameworkPath: p.join(releaseDirectory.path, 'App.xcframework'), + supplementPath: artifactManager.getIosReleaseSupplementDirectory()?.path, ); } diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart index a70de6b9..9d64a7b0 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -187,6 +187,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', .path, isCodesigned: codesign, podfileLockHash: podfileLockHash, + supplementPath: artifactManager.getIosReleaseSupplementDirectory()?.path, ); } diff --git a/packages/shorebird_cli/test/src/artifact_manager_test.dart b/packages/shorebird_cli/test/src/artifact_manager_test.dart index 42bd10d6..2ec6ca05 100644 --- a/packages/shorebird_cli/test/src/artifact_manager_test.dart +++ b/packages/shorebird_cli/test/src/artifact_manager_test.dart @@ -806,5 +806,45 @@ void main() { ); }); }); + + group('getIosReleaseSupplementDirectory', () { + group('when the directory does not exist', () { + test('returns null', () { + expect( + runWithOverrides(artifactManager.getIosReleaseSupplementDirectory), + isNull, + ); + }); + }); + + group('when the directory exists', () { + setUp(() { + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'shorebird', + ), + ).createSync(recursive: true); + }); + + test('returns path to the directory', () { + expect( + runWithOverrides( + artifactManager.getIosReleaseSupplementDirectory, + )?.path, + equals( + p.join( + projectRoot.path, + 'build', + 'ios', + 'shorebird', + ), + ), + ); + }); + }); + }); }); } diff --git a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart index 3cbda074..1df99660 100644 --- a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart +++ b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart @@ -1709,6 +1709,7 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' const podfileLockHash = 'podfile-lock-hash'; final xcarchivePath = p.join('path', 'to', 'app.xcarchive'); final runnerPath = p.join('path', 'to', 'runner.app'); + final releaseSupplementPath = p.join('path', 'to', 'supplement'); void setUpProjectRoot({String? flavor}) { Directory( @@ -1717,6 +1718,9 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' Directory( p.join(projectRoot.path, runnerPath), ).createSync(recursive: true); + Directory( + p.join(projectRoot.path, releaseSupplementPath), + ).createSync(recursive: true); } setUp(() { @@ -1761,6 +1765,7 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' runnerPath: p.join(projectRoot.path, runnerPath), isCodesigned: true, podfileLockHash: podfileLockHash, + supplementPath: p.join(projectRoot.path, releaseSupplementPath), ), ), exitsWithCode(ExitCode.software), @@ -1798,6 +1803,7 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' runnerPath: p.join(projectRoot.path, runnerPath), isCodesigned: false, podfileLockHash: podfileLockHash, + supplementPath: p.join(projectRoot.path, releaseSupplementPath), ), ), exitsWithCode(ExitCode.software), @@ -1835,6 +1841,45 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' runnerPath: p.join(projectRoot.path, runnerPath), isCodesigned: false, podfileLockHash: podfileLockHash, + supplementPath: p.join(projectRoot.path, releaseSupplementPath), + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail(any(that: contains(error)))).called(1); + }); + + test('exits with code 70 when supplement artifact creation fails', + () async { + const error = 'something went wrong'; + when( + () => codePushClient.createReleaseArtifact( + appId: any(named: 'appId'), + artifactPath: any( + named: 'artifactPath', + that: endsWith('ios_supplement.zip'), + ), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + canSideload: any(named: 'canSideload'), + podfileLockHash: any(named: 'podfileLockHash'), + ), + ).thenThrow(error); + setUpProjectRoot(); + + await expectLater( + () async => runWithOverrides( + () async => codePushClientWrapper.createIosReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + xcarchivePath: p.join(projectRoot.path, xcarchivePath), + runnerPath: p.join(projectRoot.path, runnerPath), + isCodesigned: false, + podfileLockHash: podfileLockHash, + supplementPath: p.join(projectRoot.path, releaseSupplementPath), ), ), exitsWithCode(ExitCode.software), @@ -1866,6 +1911,7 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' runnerPath: p.join(projectRoot.path, runnerPath), isCodesigned: true, podfileLockHash: podfileLockHash, + supplementPath: p.join(projectRoot.path, releaseSupplementPath), ), ); @@ -1891,44 +1937,35 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' group('createIosFrameworkReleaseArtifacts', () { final frameworkPath = p.join('path', 'to', 'App.xcframework'); + final releaseSupplementPath = p.join('path', 'to', 'supplement'); void setUpProjectRoot({String? flavor}) { Directory( p.join(projectRoot.path, frameworkPath), ).createSync(recursive: true); + Directory( + p.join(projectRoot.path, releaseSupplementPath), + ).createSync(recursive: true); } - test( - 'exits with code 70 when creating xcframework artifact fails', - () async { - when( - () => codePushClient.createReleaseArtifact( - artifactPath: any(named: 'artifactPath'), - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - arch: any(named: 'arch'), - platform: any(named: 'platform'), - hash: any(named: 'hash'), - canSideload: any(named: 'canSideload'), - podfileLockHash: any(named: 'podfileLockHash'), - ), - ).thenThrow(Exception('oh no')); - setUpProjectRoot(); + setUp(() { + when( + () => codePushClient.createReleaseArtifact( + appId: any(named: 'appId'), + artifactPath: any(named: 'artifactPath'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + canSideload: any(named: 'canSideload'), + podfileLockHash: any(named: 'podfileLockHash'), + ), + ).thenAnswer((_) async {}); + setUpProjectRoot(); + }); - await expectLater( - () async => runWithOverrides( - () => codePushClientWrapper.createIosFrameworkReleaseArtifacts( - appId: app.appId, - releaseId: releaseId, - appFrameworkPath: p.join(projectRoot.path, frameworkPath), - ), - ), - exitsWithCode(ExitCode.software), - ); - }, - ); - - test('completes successfully when release artifact is created', () async { + test('exits with code 70 when creating xcframework artifact fails', + () async { when( () => codePushClient.createReleaseArtifact( artifactPath: any(named: 'artifactPath'), @@ -1940,15 +1977,64 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' canSideload: any(named: 'canSideload'), podfileLockHash: any(named: 'podfileLockHash'), ), - ).thenAnswer((_) async {}); - setUpProjectRoot(); + ).thenThrow(Exception('oh no')); + await expectLater( + () async => runWithOverrides( + () => codePushClientWrapper.createIosFrameworkReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + appFrameworkPath: p.join(projectRoot.path, frameworkPath), + supplementPath: null, + ), + ), + exitsWithCode(ExitCode.software), + ); + }); + + test('exits with code 70 when supplement artifact creation fails', + () async { + const error = 'something went wrong'; + when( + () => codePushClient.createReleaseArtifact( + appId: any(named: 'appId'), + artifactPath: any( + named: 'artifactPath', + that: endsWith('ios_framework_supplement.zip'), + ), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + canSideload: any(named: 'canSideload'), + podfileLockHash: any(named: 'podfileLockHash'), + ), + ).thenThrow(error); + + await expectLater( + () async => runWithOverrides( + () async => + codePushClientWrapper.createIosFrameworkReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + appFrameworkPath: p.join(projectRoot.path, frameworkPath), + supplementPath: p.join(projectRoot.path, releaseSupplementPath), + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail(any(that: contains(error)))).called(1); + }); + + test('completes successfully when release artifact is created', () async { await expectLater( runWithOverrides( () => codePushClientWrapper.createIosFrameworkReleaseArtifacts( appId: app.appId, releaseId: releaseId, appFrameworkPath: p.join(projectRoot.path, frameworkPath), + supplementPath: null, ), ), completes, diff --git a/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart index 39a65bbc..74a38ef8 100644 --- a/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart @@ -143,6 +143,15 @@ void main() { }); }); + group('supplementaryReleaseArtifactArch', () { + test('is "ios_framework_supplement"', () { + expect( + patcher.supplementaryReleaseArtifactArch, + 'ios_framework_supplement', + ); + }); + }); + group('releaseType', () { test('is ReleaseType.iosFramework', () { expect(patcher.releaseType, ReleaseType.iosFramework); @@ -499,13 +508,14 @@ void main() { canSideload: true, ); late File releaseArtifactFile; + late File supplementArtifactFile; void setUpProjectRootArtifacts() { File(p.join(projectRoot.path, 'build', elfAotSnapshotFileName)) .createSync( recursive: true, ); - Directory( + File( p.join( projectRoot.path, 'build', @@ -513,13 +523,23 @@ void main() { 'framework', 'Release', 'App.xcframework', + 'ios-arm64', + 'App.framework', + 'App', ), - ).createSync( - recursive: true, - ); + ).createSync(recursive: true); File( p.join(projectRoot.path, 'build', linkFileName), ).createSync(recursive: true); + File( + p.join( + projectRoot.path, + 'build', + 'ios', + 'shorebird', + 'App.ct.link', + ), + ).createSync(recursive: true); } setUp(() { @@ -529,6 +549,12 @@ void main() { 'release.xcframework', ), )..createSync(recursive: true); + supplementArtifactFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'ios_framework_supplement.zip', + ), + )..createSync(recursive: true); when( () => codePushClientWrapper.getReleaseArtifact( @@ -828,6 +854,92 @@ void main() { ), ); }); + + group('when class table link info is not present', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async {}); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + 'Unable to find class table link info file', + ), + ).called(1); + }); + }); + + group('when class table link info is present', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: releaseArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outDir = invocation.namedArguments[#outputDirectory] + as Directory; + File( + p.join( + outDir.path, + 'ios-arm64', + 'App.framework', + 'App', + ), + ).createSync(recursive: true); + }); + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outDir = invocation.namedArguments[#outputDirectory] + as Directory; + File( + p.join(outDir.path, 'App.ct.link'), + ).createSync(recursive: true); + }); + }); + + test('returns linked patch artifact in patch bundle', () async { + final patchBundle = await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ); + + expect(patchBundle, hasLength(1)); + expect( + patchBundle[Arch.arm64], + isA().having( + (b) => b.path, + 'path', + endsWith(diffPath), + ), + ); + }); + }); }); }); diff --git a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart index 0097aaff..8bddd51a 100644 --- a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart @@ -158,6 +158,12 @@ void main() { }); }); + group('supplementaryReleaseArtifactArch', () { + test('is "ios_supplement"', () { + expect(patcher.supplementaryReleaseArtifactArch, 'ios_supplement'); + }); + }); + group('releaseType', () { test('is ReleaseType.ios', () { expect(patcher.releaseType, ReleaseType.ios); @@ -818,12 +824,12 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', canSideload: true, ); late File releaseArtifactFile; + late File supplementArtifactFile; void setUpProjectRootArtifacts() { - File(p.join(projectRoot.path, 'build', elfAotSnapshotFileName)) - .createSync( - recursive: true, - ); + File( + p.join(projectRoot.path, 'build', elfAotSnapshotFileName), + ).createSync(recursive: true); Directory( p.join( projectRoot.path, @@ -833,10 +839,8 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', 'Release', 'App.xcframework', ), - ).createSync( - recursive: true, - ); - Directory( + ).createSync(recursive: true); + File( p.join( projectRoot.path, 'build', @@ -847,10 +851,20 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', 'Products', 'Applications', 'Runner.app', + 'Frameworks', + 'App.framework', + 'App', ), - ).createSync( - recursive: true, - ); + ).createSync(recursive: true); + File( + p.join( + projectRoot.path, + 'build', + 'ios', + 'shorebird', + 'App.ct.link', + ), + ).createSync(recursive: true); File( p.join(projectRoot.path, 'build', linkFileName), ).createSync(recursive: true); @@ -863,6 +877,12 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', 'release.xcarchive', ), )..createSync(recursive: true); + supplementArtifactFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'ios_supplement.zip', + ), + )..createSync(recursive: true); when( () => codePushClientWrapper.getReleaseArtifact( @@ -1269,6 +1289,75 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', ); }); + group('when class table link info is not present', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async {}); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + 'Unable to find class table link info file', + ), + ).called(1); + }); + }); + + group('when class table link info is present', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outDir = invocation.namedArguments[#outputDirectory] + as Directory; + File( + p.join(outDir.path, 'App.ct.link'), + ).createSync(recursive: true); + }); + }); + + test('returns linked patch artifact in patch bundle', () async { + final patchBundle = await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ); + + expect(patchBundle, hasLength(1)); + expect( + patchBundle[Arch.arm64], + isA().having( + (b) => b.path, + 'path', + endsWith(diffPath), + ), + ); + }); + }); + group('when isLinkDebugInfoSupported is true', () { setUp(() { when( diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart index 8ff4c8b2..fe7a9ddb 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart @@ -24,7 +24,6 @@ import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_flutter.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; -import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; import 'package:test/test.dart'; import '../../fakes.dart'; @@ -95,6 +94,17 @@ void main() { podfileLockHash: null, canSideload: true, ); + const supplementArtifact = ReleaseArtifact( + id: 0, + releaseId: 0, + arch: arch, + platform: releasePlatform, + hash: '#', + size: 422, + url: 'https://example.com/supplement.zip', + podfileLockHash: null, + canSideload: false, + ); late AotTools aotTools; late ArgResults argResults; @@ -225,6 +235,14 @@ void main() { platform: ReleasePlatform.android, ), ).thenAnswer((_) async => aabArtifact); + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: 'supplement', + platform: ReleasePlatform.android, + ), + ).thenAnswer((_) async => supplementArtifact); when( () => logger.chooseOne( @@ -253,6 +271,7 @@ void main() { appId: any(named: 'appId'), releaseId: any(named: 'releaseId'), releaseArtifact: any(named: 'releaseArtifact'), + supplementArtifact: any(named: 'supplementArtifact'), ), ).thenAnswer((_) async => patchArtifactBundles); when( @@ -427,32 +446,84 @@ void main() { }, ); - group( - 'when given an existing public key and nonexistent private key', - () { - test('fails and logs the err', () async { - when( - () => argResults.wasParsed(CommonArguments.privateKeyArg.name), - ).thenReturn(false); - when( - () => argResults.wasParsed(CommonArguments.publicKeyArg.name), - ).thenReturn(true); - when( - () => argResults[CommonArguments.publicKeyArg.name], - ).thenReturn(createTempFile('public.pem').path); + group('when given an existing public key and nonexistent private key', + () { + test('fails and logs the err', () async { + when( + () => argResults.wasParsed(CommonArguments.privateKeyArg.name), + ).thenReturn(false); + when( + () => argResults.wasParsed(CommonArguments.publicKeyArg.name), + ).thenReturn(true); + when( + () => argResults[CommonArguments.publicKeyArg.name], + ).thenReturn(createTempFile('public.pem').path); - await expectLater( - runWithOverrides(() => command.createPatch(patcher)), - exitsWithCode(ExitCode.usage), - ); + await expectLater( + runWithOverrides(() => command.createPatch(patcher)), + exitsWithCode(ExitCode.usage), + ); + verify( + () => logger.err( + 'Both public and private keys must be provided.', + ), + ).called(1); + }); + }); + + group('when a supplemental release artifact exists', () { + setUp(() { + when( + () => patcher.supplementaryReleaseArtifactArch, + ).thenReturn('supplement'); + }); + + test('downloads the supplemental release artifact', () async { + await runWithOverrides(() => command.createPatch(patcher)); + + verify( + () => codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: 'supplement', + platform: releasePlatform, + ), + ).called(1); + verify( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: release.id, + releaseArtifact: any(named: 'releaseArtifact'), + supplementArtifact: any(named: 'supplementArtifact'), + ), + ).called(1); + }); + + group('when the artifact is not found', () { + setUp(() { + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: 'supplement', + platform: releasePlatform, + ), + ).thenThrow(CodePushNotFoundException(message: 'Not found')); + }); + + test('gracefully continues to create patch', () async { + await runWithOverrides(() => command.createPatch(patcher)); verify( - () => logger.err( - 'Both public and private keys must be provided.', + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: release.id, + releaseArtifact: any(named: 'releaseArtifact'), + supplementArtifact: any(named: 'supplementArtifact'), ), ).called(1); }); - }, - ); + }); + }); }); }); @@ -518,6 +589,10 @@ void main() { ).thenReturn(DeploymentTrack.staging.channel); }); + test('isStaging returns true', () { + expect(command.isStaging, isTrue); + }); + test('logs correct summary', () async { final expectedSummary = [ '''📱 App: ${lightCyan.wrap(appDisplayName)} ${lightCyan.wrap('($appId)')}''', diff --git a/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart index 18d7eba6..84cdffc5 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart @@ -36,6 +36,20 @@ void main() { }); }); + group('supplementaryReleaseArtifactArch', () { + test('defaults to null', () { + expect( + _TestPatcher( + argParser: MockArgParser(), + argResults: MockArgResults(), + flavor: null, + target: null, + ).supplementaryReleaseArtifactArch, + isNull, + ); + }); + }); + group('assertArgsAreValid', () { test('has no validations by default', () { expect( @@ -261,6 +275,7 @@ class _TestPatcher extends Patcher { required String appId, required int releaseId, required File releaseArtifact, + File? supplementArtifact, }) { throw UnimplementedError(); } diff --git a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart index f97dfa29..b81b537f 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart @@ -399,6 +399,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', appId: any(named: 'appId'), releaseId: any(named: 'releaseId'), appFrameworkPath: any(named: 'appFrameworkPath'), + supplementPath: any(named: 'supplementPath'), ), ).thenAnswer((_) async {}); }); @@ -420,6 +421,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', 'release', ArtifactManager.appXcframeworkName, ), + supplementPath: null, ), ).called(1); }); diff --git a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart index 3f68357b..e1ff3b58 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart @@ -677,6 +677,7 @@ To change the version of this release, change your app's version in your pubspec late Directory xcarchiveDirectory; late Directory iosAppDirectory; + late Directory supplementDirectory; late File podfileLockFile; setUp(() { @@ -684,6 +685,7 @@ To change the version of this release, change your app's version in your pubspec xcarchiveDirectory = Directory.systemTemp.createTempSync(); iosAppDirectory = Directory.systemTemp.createTempSync(); + supplementDirectory = Directory.systemTemp.createTempSync(); podfileLockFile = File( p.join( Directory.systemTemp.createTempSync().path, @@ -699,6 +701,9 @@ To change the version of this release, change your app's version in your pubspec xcarchiveDirectory: any(named: 'xcarchiveDirectory'), ), ).thenReturn(iosAppDirectory); + when( + () => artifactManager.getIosReleaseSupplementDirectory(), + ).thenReturn(supplementDirectory); when( () => codePushClientWrapper.createIosReleaseArtifacts( appId: any(named: 'appId'), @@ -707,6 +712,7 @@ To change the version of this release, change your app's version in your pubspec runnerPath: any(named: 'runnerPath'), isCodesigned: any(named: 'isCodesigned'), podfileLockHash: any(named: 'podfileLockHash'), + supplementPath: any(named: 'supplementPath'), ), ).thenAnswer((_) async => {}); when(() => shorebirdEnv.podfileLockFile).thenReturn(podfileLockFile); @@ -728,7 +734,8 @@ To change the version of this release, change your app's version in your pubspec runnerPath: iosAppDirectory.path, isCodesigned: codesign, podfileLockHash: - sha256.convert(utf8.encode(podfileLockContent)).toString(), + '${sha256.convert(utf8.encode(podfileLockContent))}', + supplementPath: supplementDirectory.path, ), ).called(1); });