From 95f267deeff1f3620ec7c915f29f4e08a88e32c6 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 9 May 2024 14:17:35 -0400 Subject: [PATCH] refactor(shorebird_cli): delete old patch command and mixins (#2052) --- .../shorebird_cli/lib/src/command_runner.dart | 2 - .../src/commands/build/build_ipa_command.dart | 10 +- .../{patch_new => patch}/aar_patcher.dart | 2 +- .../{patch_new => patch}/android_patcher.dart | 2 +- .../ios_framework_patcher.dart | 2 +- .../{patch_new => patch}/ios_patcher.dart | 2 +- .../lib/src/commands/patch/patch.dart | 9 +- .../src/commands/patch/patch_aar_command.dart | 368 ---- .../commands/patch/patch_android_command.dart | 387 ---- .../lib/src/commands/patch/patch_command.dart | 353 +++- .../src/commands/patch/patch_ios_command.dart | 600 ------ .../patch/patch_ios_framework_command.dart | 409 ---- .../{patch_new => patch}/patcher.dart | 0 .../lib/src/commands/patch_new/patch_new.dart | 6 - .../commands/patch_new/patch_new_command.dart | 354 ---- .../src/commands/release/release_command.dart | 7 +- .../lib/src/shorebird_artifact_mixin.dart | 162 -- .../lib/src/shorebird_build_mixin.dart | 348 ---- .../src/shorebird_release_version_mixin.dart | 23 - .../build/build_ipa_command_test.dart | 112 +- .../aar_patcher_test.dart | 2 +- .../android_patcher_test.dart | 2 +- .../ios_framework_patcher_test.dart | 2 +- .../ios_patcher_test.dart | 2 +- .../patch/patch_aar_command_test.dart | 930 --------- .../patch/patch_android_command_test.dart | 1089 ---------- .../patch_command_test.dart} | 12 +- .../patch/patch_ios_command_test.dart | 1794 ----------------- .../patch_ios_framework_command_test.dart | 1216 ----------- packages/shorebird_cli/test/src/mocks.dart | 2 +- 30 files changed, 386 insertions(+), 7823 deletions(-) rename packages/shorebird_cli/lib/src/commands/{patch_new => patch}/aar_patcher.dart (98%) rename packages/shorebird_cli/lib/src/commands/{patch_new => patch}/android_patcher.dart (98%) rename packages/shorebird_cli/lib/src/commands/{patch_new => patch}/ios_framework_patcher.dart (99%) rename packages/shorebird_cli/lib/src/commands/{patch_new => patch}/ios_patcher.dart (99%) delete mode 100644 packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart delete mode 100644 packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart delete mode 100644 packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart delete mode 100644 packages/shorebird_cli/lib/src/commands/patch/patch_ios_framework_command.dart rename packages/shorebird_cli/lib/src/commands/{patch_new => patch}/patcher.dart (100%) delete mode 100644 packages/shorebird_cli/lib/src/commands/patch_new/patch_new.dart delete mode 100644 packages/shorebird_cli/lib/src/commands/patch_new/patch_new_command.dart delete mode 100644 packages/shorebird_cli/lib/src/shorebird_artifact_mixin.dart delete mode 100644 packages/shorebird_cli/lib/src/shorebird_build_mixin.dart delete mode 100644 packages/shorebird_cli/lib/src/shorebird_release_version_mixin.dart rename packages/shorebird_cli/test/src/commands/{patch_new => patch}/aar_patcher_test.dart (99%) rename packages/shorebird_cli/test/src/commands/{patch_new => patch}/android_patcher_test.dart (99%) rename packages/shorebird_cli/test/src/commands/{patch_new => patch}/ios_framework_patcher_test.dart (99%) rename packages/shorebird_cli/test/src/commands/{patch_new => patch}/ios_patcher_test.dart (99%) delete mode 100644 packages/shorebird_cli/test/src/commands/patch/patch_aar_command_test.dart delete mode 100644 packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart rename packages/shorebird_cli/test/src/commands/{patch_new/patch_new_command_test.dart => patch/patch_command_test.dart} (98%) delete mode 100644 packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart delete mode 100644 packages/shorebird_cli/test/src/commands/patch/patch_ios_framework_command_test.dart diff --git a/packages/shorebird_cli/lib/src/command_runner.dart b/packages/shorebird_cli/lib/src/command_runner.dart index a100c986..150f1c23 100644 --- a/packages/shorebird_cli/lib/src/command_runner.dart +++ b/packages/shorebird_cli/lib/src/command_runner.dart @@ -6,7 +6,6 @@ import 'package:cli_completion/cli_completion.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/commands/commands.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; import 'package:shorebird_cli/src/engine_config.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/platform.dart'; @@ -74,7 +73,6 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { addCommand(LoginCiCommand()); addCommand(LogoutCommand()); addCommand(PatchCommand()); - addCommand(PatchNewCommand()); addCommand(PreviewCommand()); addCommand(ReleaseCommand()); addCommand(RunCommand()); diff --git a/packages/shorebird_cli/lib/src/commands/build/build_ipa_command.dart b/packages/shorebird_cli/lib/src/commands/build/build_ipa_command.dart index 0e152bde..38a4e573 100644 --- a/packages/shorebird_cli/lib/src/commands/build/build_ipa_command.dart +++ b/packages/shorebird_cli/lib/src/commands/build/build_ipa_command.dart @@ -1,11 +1,9 @@ -import 'dart:io'; - import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/command.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; /// {@template build_ipa_command} @@ -13,7 +11,7 @@ import 'package:shorebird_cli/src/shorebird_validator.dart'; /// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for /// App Store submission. /// {@endtemplate} -class BuildIpaCommand extends ShorebirdCommand with ShorebirdBuildMixin { +class BuildIpaCommand extends ShorebirdCommand { /// {@macro build_ipa_command} BuildIpaCommand() { argParser @@ -63,12 +61,12 @@ Codesigning is disabled. You must manually codesign before deploying to devices. final buildProgress = logger.progress('Building ipa'); try { - await buildIpa( + await artifactBuilder.buildIpa( flavor: flavor, target: target, codesign: codesign, ); - } on ProcessException catch (error) { + } on ArtifactBuildException catch (error) { buildProgress.fail('Failed to build: ${error.message}'); return ExitCode.software.code; } diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/aar_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart similarity index 98% rename from packages/shorebird_cli/lib/src/commands/patch_new/aar_patcher.dart rename to packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart index c2cefb5a..df9dbed0 100644 --- a/packages/shorebird_cli/lib/src/commands/patch_new/aar_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart @@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/platform.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/android_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart similarity index 98% rename from packages/shorebird_cli/lib/src/commands/patch_new/android_patcher.dart rename to packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart index d3283bf6..a8a5792c 100644 --- a/packages/shorebird_cli/lib/src/commands/patch_new/android_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart @@ -6,7 +6,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patcher.dart'; +import 'package:shorebird_cli/src/commands/patch/patcher.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/ios_framework_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart similarity index 99% rename from packages/shorebird_cli/lib/src/commands/patch_new/ios_framework_patcher.dart rename to packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart index 1e80aeca..e70d045d 100644 --- a/packages/shorebird_cli/lib/src/commands/patch_new/ios_framework_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart @@ -9,7 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/ios_archive_differ.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/aot_tools.dart'; import 'package:shorebird_cli/src/executables/xcodebuild.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/ios_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart similarity index 99% rename from packages/shorebird_cli/lib/src/commands/patch_new/ios_patcher.dart rename to packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart index ed9f57f9..b8c17286 100644 --- a/packages/shorebird_cli/lib/src/commands/patch_new/ios_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart @@ -9,7 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patcher.dart'; +import 'package:shorebird_cli/src/commands/patch/patcher.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/logger.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch.dart b/packages/shorebird_cli/lib/src/commands/patch/patch.dart index efc9dcdd..ca8f935c 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch.dart @@ -1,5 +1,6 @@ -export 'patch_aar_command.dart'; -export 'patch_android_command.dart'; +export 'aar_patcher.dart'; +export 'android_patcher.dart'; +export 'ios_framework_patcher.dart'; +export 'ios_patcher.dart'; export 'patch_command.dart'; -export 'patch_ios_command.dart'; -export 'patch_ios_framework_command.dart'; +export 'patcher.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart deleted file mode 100644 index 809710e9..00000000 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart +++ /dev/null @@ -1,368 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:archive/archive_io.dart'; -import 'package:collection/collection.dart'; -import 'package:crypto/crypto.dart'; -import 'package:mason_logger/mason_logger.dart'; -import 'package:path/path.dart' as p; -import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; -import 'package:shorebird_cli/src/artifact_manager.dart'; -import 'package:shorebird_cli/src/cache.dart'; -import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/commands/patch/patch.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/deployment_track.dart'; -import 'package:shorebird_cli/src/formatters/file_size_formatter.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/shorebird_artifact_mixin.dart'; -import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; -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_cli/src/version.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; - -/// {@template patch_aar_command} -/// `shorebird patch aar` -/// Create a patch for an Android archive release. -/// {@endtemplate} -class PatchAarCommand extends ShorebirdCommand - with ShorebirdBuildMixin, ShorebirdArtifactMixin { - /// {@macro patch_aar_command} - PatchAarCommand({ - HashFunction? hashFn, - UnzipFn? unzipFn, - AndroidArchiveDiffer? archiveDiffer, - }) : _archiveDiffer = archiveDiffer ?? AndroidArchiveDiffer(), - _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()), - _unzipFn = unzipFn ?? extractFileToDisk { - argParser - ..addOption( - 'build-number', - help: 'The build number of the module (e.g. "1.0.0").', - defaultsTo: '1.0', - ) - ..addOption( - 'release-version', - help: ''' -The version of the associated release (e.g. "1.0.0"). This should be the version -of the Android app that is using this module.''', - mandatory: true, - ) - ..addFlag( - 'allow-native-diffs', - help: PatchCommand.allowNativeDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'allow-asset-diffs', - help: PatchCommand.allowAssetDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'dry-run', - abbr: 'n', - negatable: false, - help: 'Validate but do not upload the patch.', - ); - } - - @override - String get name => 'aar'; - - @override - String get description => - 'Publish new patches for a specific Android archive release to Shorebird'; - - final AndroidArchiveDiffer _archiveDiffer; - final HashFunction _hashFn; - final UnzipFn _unzipFn; - - @override - Future run() async { - try { - await shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - ); - } on PreconditionFailedException catch (e) { - return e.exitCode.code; - } - - final dryRun = results['dry-run'] == true; - final allowAssetDiffs = results['allow-asset-diffs'] == true; - final allowNativeDiffs = results['allow-native-diffs'] == true; - - await cache.updateAll(); - - if (shorebirdEnv.androidPackageName == null) { - logger.err('Could not find androidPackage in pubspec.yaml.'); - return ExitCode.config.code; - } - - final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!; - final appId = shorebirdYaml.getAppId(); - final app = await codePushClientWrapper.getApp(appId: appId); - final releases = await codePushClientWrapper.getReleases(appId: appId); - - if (releases.isEmpty) { - logger.info('No releases found'); - return ExitCode.success.code; - } - - final releaseVersion = results['release-version'] as String? ?? - await _promptForReleaseVersion(releases); - - final release = releases.firstWhereOrNull( - (r) => r.version == releaseVersion, - ); - - if (releaseVersion == null || release == null) { - logger.info(''' -No release found for version $releaseVersion - -Available release versions: -${releases.map((r) => r.version).join('\n')}'''); - return ExitCode.success.code; - } - - if (release.platformStatuses[ReleasePlatform.android] == - ReleaseStatus.draft) { - logger.err(''' -Release $releaseVersion is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''); - return ExitCode.software.code; - } - - const releasePlatform = ReleasePlatform.android; - final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts( - appId: appId, - releaseId: release.id, - architectures: AndroidArch.availableAndroidArchs, - platform: releasePlatform, - ); - - final releaseAarArtifact = await codePushClientWrapper.getReleaseArtifact( - appId: appId, - releaseId: release.id, - arch: 'aar', - platform: releasePlatform, - ); - - final Map releaseArtifactPaths; - try { - releaseArtifactPaths = await _downloadReleaseArtifacts( - releaseArtifacts: releaseArtifacts, - ); - } catch (_) { - return ExitCode.software.code; - } - - try { - await shorebirdFlutter.installRevision(revision: release.flutterRevision); - } catch (_) { - return ExitCode.software.code; - } - - final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith( - flutterRevisionOverride: release.flutterRevision, - ); - - return await runScoped( - () async { - final buildNumber = results['build-number'] as String; - final flutterVersionString = - await shorebirdFlutter.getVersionAndRevision(); - final buildProgress = logger.progress( - 'Building patch with Flutter $flutterVersionString', - ); - try { - await buildAar(buildNumber: buildNumber); - buildProgress.complete(); - } on ProcessException catch (error) { - buildProgress.fail('Failed to build: ${error.message}'); - return ExitCode.software.code; - } - - final extractedAarDir = await extractAar( - packageName: shorebirdEnv.androidPackageName!, - buildNumber: buildNumber, - unzipFn: _unzipFn, - ); - - final DiffStatus diffStatus; - try { - diffStatus = - await patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: File( - aarArtifactPath( - packageName: shorebirdEnv.androidPackageName!, - buildNumber: buildNumber, - ), - ), - releaseArtifact: await artifactManager - .downloadFile(Uri.parse(releaseAarArtifact.url)), - archiveDiffer: _archiveDiffer, - allowAssetChanges: allowAssetDiffs, - allowNativeChanges: allowNativeDiffs, - ); - } on UserCancelledException { - return ExitCode.success.code; - } on UnpatchableChangeException { - logger.info('Exiting.'); - return ExitCode.software.code; - } - - final patchArtifactBundles = await _createPatchArtifacts( - releaseArtifactPaths: releaseArtifactPaths, - extractedAarDirectory: extractedAarDir, - ); - if (patchArtifactBundles == null) { - return ExitCode.software.code; - } - - final archMetadata = patchArtifactBundles.keys.map((arch) { - final size = formatBytes(patchArtifactBundles[arch]!.size); - return '${arch.name} ($size)'; - }); - - if (dryRun) { - logger - ..info('No issues detected.') - ..info('The server may enforce additional checks.'); - return ExitCode.success.code; - } - - final summary = [ - '''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''', - 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''', - 'đŸŸĸ Track: ${lightCyan.wrap('Production')}', - ]; - - logger.info( - ''' - -${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))} - -${summary.join('\n')} -''', - ); - - if (shorebirdEnv.canAcceptUserInput) { - final confirm = logger.confirm('Would you like to continue?'); - - if (!confirm) { - logger.info('Aborting.'); - return ExitCode.success.code; - } - } - - await codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - track: DeploymentTrack.production, - patchArtifactBundles: patchArtifactBundles, - metadata: CreatePatchMetadata( - releasePlatform: releasePlatform, - usedIgnoreAssetChangesFlag: allowAssetDiffs, - hasAssetChanges: diffStatus.hasAssetChanges, - usedIgnoreNativeChangesFlag: allowNativeDiffs, - hasNativeChanges: diffStatus.hasNativeChanges, - linkPercentage: null, - environment: BuildEnvironmentMetadata( - operatingSystem: platform.operatingSystem, - operatingSystemVersion: platform.operatingSystemVersion, - shorebirdVersion: packageVersion, - xcodeVersion: null, - ), - ), - ); - - return ExitCode.success.code; - }, - values: { - shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv), - }, - ); - } - - Future _promptForReleaseVersion(List releases) async { - if (releases.isEmpty) return null; - final release = logger.chooseOne( - 'Which release would you like to patch?', - choices: releases, - display: (release) => release.version, - ); - return release.version; - } - - Future?> _createPatchArtifacts({ - required Map releaseArtifactPaths, - required String extractedAarDirectory, - }) async { - final patchArtifactBundles = {}; - - final createDiffProgress = logger.progress('Creating artifacts'); - for (final releaseArtifactPath in releaseArtifactPaths.entries) { - final arch = releaseArtifactPath.key; - final artifactPath = p.join( - extractedAarDirectory, - 'jni', - arch.androidBuildPath, - 'libapp.so', - ); - logger.detail('Creating artifact for $artifactPath'); - final patchArtifact = File(artifactPath); - final hash = _hashFn(await patchArtifact.readAsBytes()); - try { - final diffPath = await artifactManager.createDiff( - releaseArtifactPath: releaseArtifactPath.value, - patchArtifactPath: artifactPath, - ); - patchArtifactBundles[arch] = PatchArtifactBundle( - arch: arch.arch, - path: diffPath, - hash: hash, - size: await File(diffPath).length(), - ); - } catch (error) { - createDiffProgress.fail('$error'); - return null; - } - } - createDiffProgress.complete(); - - return patchArtifactBundles; - } - - Future> _downloadReleaseArtifacts({ - required Map releaseArtifacts, - }) async { - final releaseArtifactPaths = {}; - final downloadReleaseArtifactProgress = logger.progress( - 'Downloading release artifacts', - ); - for (final releaseArtifact in releaseArtifacts.entries) { - try { - final releaseArtifactFile = await artifactManager.downloadFile( - Uri.parse(releaseArtifact.value.url), - ); - releaseArtifactPaths[releaseArtifact.key] = releaseArtifactFile.path; - } catch (error) { - downloadReleaseArtifactProgress.fail('$error'); - rethrow; - } - } - - downloadReleaseArtifactProgress.complete(); - return releaseArtifactPaths; - } -} diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart deleted file mode 100644 index ed3d098b..00000000 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart +++ /dev/null @@ -1,387 +0,0 @@ -import 'dart:io'; - -import 'package:crypto/crypto.dart'; -import 'package:mason_logger/mason_logger.dart'; -import 'package:path/path.dart' as p; -import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart'; -import 'package:shorebird_cli/src/artifact_manager.dart'; -import 'package:shorebird_cli/src/cache.dart'; -import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/commands/commands.dart'; -import 'package:shorebird_cli/src/config/shorebird_yaml.dart'; -import 'package:shorebird_cli/src/deployment_track.dart'; -import 'package:shorebird_cli/src/doctor.dart'; -import 'package:shorebird_cli/src/extensions/arg_results.dart'; -import 'package:shorebird_cli/src/formatters/formatters.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_flutter.dart'; -import 'package:shorebird_cli/src/shorebird_release_version_mixin.dart'; -import 'package:shorebird_cli/src/shorebird_validator.dart'; -import 'package:shorebird_cli/src/version.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; - -/// {@template patch_android_command} -/// `shorebird patch android` -/// Publish new patches for a specific Android release to the Shorebird code -/// push server. -/// {@endtemplate} -class PatchAndroidCommand extends ShorebirdCommand - with ShorebirdBuildMixin, ShorebirdReleaseVersionMixin { - /// {@macro patch_android_command} - PatchAndroidCommand({ - HashFunction? hashFn, - AndroidArchiveDiffer? archiveDiffer, - }) : _archiveDiffer = archiveDiffer ?? AndroidArchiveDiffer(), - _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()) { - argParser - ..addOption( - 'target', - abbr: 't', - help: 'The main entrypoint file of the application.', - ) - ..addOption( - 'flavor', - help: 'The product flavor to use when building the app.', - ) - ..addOption( - 'release-version', - help: ''' -The version of the release being patched (e.g. "1.0.0+1"). - -If this option is not provided, the version number will be determined from the patch artifact.''', - ) - ..addFlag( - 'allow-native-diffs', - help: PatchCommand.allowNativeDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'allow-asset-diffs', - help: PatchCommand.allowAssetDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'dry-run', - abbr: 'n', - negatable: false, - help: 'Validate but do not upload the patch.', - ) - ..addFlag( - 'staging', - negatable: false, - help: 'Whether to publish the patch to the staging environment.', - ); - } - - @override - String get description => - 'Publish new patches for a specific Android release to Shorebird.'; - - @override - String get name => 'android'; - - final ArchiveDiffer _archiveDiffer; - final HashFunction _hashFn; - - @override - Future run() async { - try { - await shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - validators: doctor.androidCommandValidators, - ); - } on PreconditionFailedException catch (e) { - return e.exitCode.code; - } - - final allowAssetDiffs = results['allow-asset-diffs'] == true; - final allowNativeDiffs = results['allow-native-diffs'] == true; - final dryRun = results['dry-run'] == true; - final isStaging = results['staging'] == true; - - await cache.updateAll(); - - const releasePlatform = ReleasePlatform.android; - final flavor = results.findOption('flavor', argParser: argParser); - final target = results.findOption('target', argParser: argParser); - - final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!; - final appId = shorebirdYaml.getAppId(flavor: flavor); - final app = await codePushClientWrapper.getApp(appId: appId); - - var hasBuiltWithActiveFlutter = false; - late File aabFile; - - final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; - - final String releaseVersion; - final argReleaseVersion = results['release-version'] as String?; - if (argReleaseVersion != null) { - logger.detail('Using release version $argReleaseVersion from argument.'); - releaseVersion = argReleaseVersion; - } else { - logger.detail('No release version provided. Determining from bundle.'); - final flutterVersionString = - await shorebirdFlutter.getVersionAndRevision(); - final buildProgress = logger.progress( - 'Building patch with Flutter $flutterVersionString', - ); - try { - aabFile = await buildAppBundle(flavor: flavor, target: target); - buildProgress.complete(); - } on BuildException catch (error) { - buildProgress.fail(error.message); - return ExitCode.software.code; - } - - hasBuiltWithActiveFlutter = true; - - final detectReleaseVersionProgress = logger.progress( - 'Detecting release version', - ); - - try { - releaseVersion = await extractReleaseVersionFromAppBundle(aabFile.path); - detectReleaseVersionProgress.complete( - 'Detected release version $releaseVersion', - ); - } catch (error) { - detectReleaseVersionProgress.fail('$error'); - return ExitCode.software.code; - } - } - - final release = await codePushClientWrapper.getRelease( - appId: appId, - releaseVersion: releaseVersion, - ); - - if (release.platformStatuses[ReleasePlatform.android] == - ReleaseStatus.draft) { - logger.err(''' -Release $releaseVersion is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''); - return ExitCode.software.code; - } - - final currentFlutterRevision = shorebirdEnv.flutterRevision; - if (release.flutterRevision != currentFlutterRevision) { - logger.info( - 'Release was built with Flutter revision ${release.flutterRevision}', - ); - } - - try { - await shorebirdFlutter.installRevision(revision: release.flutterRevision); - } catch (_) { - return ExitCode.software.code; - } - - final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith( - flutterRevisionOverride: release.flutterRevision, - ); - - return await runScoped( - () async { - if (!hasBuiltWithActiveFlutter || - currentFlutterRevision != release.flutterRevision) { - final buildProgress = logger.progress('Building patch'); - try { - aabFile = await buildAppBundle(flavor: flavor, target: target); - buildProgress.complete(); - } on BuildException catch (error) { - buildProgress.fail(error.message); - return ExitCode.software.code; - } - } - - final patchArchsBuildDir = ArtifactManager.androidArchsDirectory( - projectRoot: projectRoot, - flavor: flavor, - ); - - if (patchArchsBuildDir == null) { - logger - ..err('Cannot find patch build artifacts.') - ..info( - ''' -Please run `shorebird cache clean` and try again. If the issue persists, please -file a bug report at https://github.com/shorebirdtech/shorebird/issues/new. - -Looked in: - - build/app/intermediates/stripped_native_libs/stripReleaseDebugSymbols/release/out/lib - - build/app/intermediates/stripped_native_libs/strip{flavor}ReleaseDebugSymbols/{flavor}Release/out/lib - - build/app/intermediates/stripped_native_libs/release/out/lib - - build/app/intermediates/stripped_native_libs/{flavor}Release/out/lib''', - ); - - return ExitCode.software.code; - } - - final releaseArtifacts = - await codePushClientWrapper.getReleaseArtifacts( - appId: app.appId, - releaseId: release.id, - architectures: AndroidArch.availableAndroidArchs, - platform: releasePlatform, - ); - - final releaseAabArtifact = - await codePushClientWrapper.getReleaseArtifact( - appId: app.appId, - releaseId: release.id, - arch: 'aab', - platform: releasePlatform, - ); - - final releaseArtifactPaths = {}; - final downloadReleaseArtifactProgress = logger.progress( - 'Downloading release artifacts', - ); - for (final releaseArtifact in releaseArtifacts.entries) { - try { - final releaseArtifactFile = await artifactManager.downloadFile( - Uri.parse(releaseArtifact.value.url), - ); - releaseArtifactPaths[releaseArtifact.key] = - releaseArtifactFile.path; - } catch (error) { - downloadReleaseArtifactProgress.fail('$error'); - return ExitCode.software.code; - } - } - - final releaseAabArtifactFile = await artifactManager.downloadFile( - Uri.parse(releaseAabArtifact.url), - ); - - downloadReleaseArtifactProgress.complete(); - - final DiffStatus diffStatus; - try { - diffStatus = - await patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: aabFile, - releaseArtifact: releaseAabArtifactFile, - archiveDiffer: _archiveDiffer, - allowAssetChanges: allowAssetDiffs, - allowNativeChanges: allowNativeDiffs, - ); - } on UserCancelledException { - return ExitCode.success.code; - } on UnpatchableChangeException { - logger.info('Exiting.'); - return ExitCode.software.code; - } - - final patchArtifactBundles = {}; - final createDiffProgress = logger.progress('Creating artifacts'); - for (final releaseArtifactPath in releaseArtifactPaths.entries) { - final arch = releaseArtifactPath.key; - final patchArtifactPath = p.join( - patchArchsBuildDir.path, - arch.androidBuildPath, - 'libapp.so', - ); - logger.detail('Creating artifact for $patchArtifactPath'); - final patchArtifact = File(patchArtifactPath); - final hash = _hashFn(await patchArtifact.readAsBytes()); - try { - final diffPath = await artifactManager.createDiff( - releaseArtifactPath: releaseArtifactPath.value, - patchArtifactPath: patchArtifactPath, - ); - patchArtifactBundles[releaseArtifactPath.key] = PatchArtifactBundle( - arch: arch.arch, - path: diffPath, - hash: hash, - size: await File(diffPath).length(), - ); - } catch (error) { - createDiffProgress.fail('$error'); - return ExitCode.software.code; - } - } - createDiffProgress.complete(); - - final archMetadata = patchArtifactBundles.keys.map((arch) { - final size = formatBytes(patchArtifactBundles[arch]!.size); - return '${arch.name} ($size)'; - }); - - if (dryRun) { - logger - ..info('No issues detected.') - ..info('The server may enforce additional checks.'); - return ExitCode.success.code; - } - - final summary = [ - '''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''', - if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}', - 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''', - if (isStaging) - '🟠 Track: ${lightCyan.wrap('Staging')}' - else - 'đŸŸĸ Track: ${lightCyan.wrap('Production')}', - ]; - - logger.info( - ''' - -${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))} - -${summary.join('\n')} -''', - ); - - if (shorebirdEnv.canAcceptUserInput) { - final confirm = logger.confirm('Would you like to continue?'); - - if (!confirm) { - logger.info('Aborting.'); - return ExitCode.success.code; - } - } - - await codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - metadata: CreatePatchMetadata( - releasePlatform: releasePlatform, - usedIgnoreAssetChangesFlag: allowAssetDiffs, - hasAssetChanges: diffStatus.hasAssetChanges, - usedIgnoreNativeChangesFlag: allowNativeDiffs, - hasNativeChanges: diffStatus.hasNativeChanges, - linkPercentage: null, - environment: BuildEnvironmentMetadata( - operatingSystem: platform.operatingSystem, - operatingSystemVersion: platform.operatingSystemVersion, - shorebirdVersion: packageVersion, - xcodeVersion: null, - ), - ), - platform: releasePlatform, - track: - isStaging ? DeploymentTrack.staging : DeploymentTrack.production, - patchArtifactBundles: patchArtifactBundles, - ); - - return ExitCode.success.code; - }, - values: { - shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv), - }, - ); - } -} 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 866c043e..9c682d57 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -1,18 +1,96 @@ import 'package:mason_logger/mason_logger.dart'; +import 'package:meta/meta.dart'; +import 'package:scoped/scoped.dart'; +import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/cache.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/deployment_track.dart'; +import 'package:shorebird_cli/src/extensions/arg_results.dart'; +import 'package:shorebird_cli/src/formatters/formatters.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/patch_diff_checker.dart'; +import 'package:shorebird_cli/src/platform/platform.dart'; +import 'package:shorebird_cli/src/release_type.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +typedef ResolvePatcher = Patcher Function(ReleaseType releaseType); -/// {@template patch_command} -/// `shorebird patch` -/// Create new app release patch. -/// {@endtemplate} class PatchCommand extends ShorebirdCommand { - /// {@macro patch_command} - PatchCommand() { - addSubcommand(PatchAarCommand()); - addSubcommand(PatchAndroidCommand()); - addSubcommand(PatchIosCommand()); - addSubcommand(PatchIosFrameworkCommand()); + PatchCommand({ + ResolvePatcher? resolvePatcher, + }) { + _resolvePatcher = resolvePatcher ?? getPatcher; + argParser + ..addMultiOption( + 'platform', + abbr: 'p', + help: 'The platform(s) to to build this release for.', + allowed: ReleaseType.values.map((e) => e.cliName).toList(), + // TODO(bryanoltman): uncomment this once https://github.com/dart-lang/args/pull/273 lands + // mandatory: true. + ) + ..addOption( + 'build-number', + help: ''' +An identifier used as an internal version number. +Each build must have a unique identifier to differentiate it from previous builds. +It is used to determine whether one build is more recent than another, with higher numbers indicating more recent build. +On Android it is used as "versionCode". +On Xcode builds it is used as "CFBundleVersion".''', + defaultsTo: '1.0', + ) + ..addOption( + 'target', + abbr: 't', + help: 'The main entrypoint file of the application.', + ) + ..addOption( + 'flavor', + help: 'The product flavor to use when building the app.', + ) + ..addOption( + 'release-version', + help: ''' +The version of the associated release (e.g. "1.0.0"). This should be the version +of the iOS app that is using this module.''', + ) + ..addFlag( + 'allow-native-diffs', + help: allowNativeDiffsHelpText, + negatable: false, + ) + ..addFlag( + 'allow-asset-diffs', + help: allowAssetDiffsHelpText, + negatable: false, + ) + ..addFlag( + 'staging', + negatable: false, + help: 'Whether to publish the patch to the staging environment.', + ) + ..addOption( + exportOptionsPlistArgName, + help: + '''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys (iOS only).''', + ) + ..addFlag( + 'codesign', + help: 'Codesign the application bundle (iOS only).', + defaultsTo: true, + ) + ..addFlag( + 'dry-run', + abbr: 'n', + negatable: false, + help: 'Validate but do not upload the patch.', + ); } static final allowNativeDiffsHelpText = ''' @@ -23,10 +101,261 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Native code changes cannot b Patch even if asset diffs are detected. NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be included in a patch can cause your app to behave unexpectedly.'''; + late final ResolvePatcher _resolvePatcher; + @override String get description => - 'Manage patches for a specific release in Shorebird.'; + 'Creates a shorebird patch for the provided target platforms'; @override String get name => 'patch'; + + /// The shorebird app ID for the current project. + String get appId => shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); + + /// The build flavor, if provided. + late String? flavor = results.findOption('flavor', argParser: argParser); + + /// The target script, if provided. + late String? target = results.findOption('target', argParser: argParser); + + bool get isStaging => results['staging'] == true; + + @override + Future run() async { + final patcherFutures = + results.releaseTypes.map(_resolvePatcher).map(createPatch); + + for (final patcherFuture in patcherFutures) { + await patcherFuture; + } + + return ExitCode.success.code; + } + + @visibleForTesting + Patcher getPatcher(ReleaseType releaseType) { + switch (releaseType) { + case ReleaseType.android: + return AndroidPatcher( + argResults: results, + flavor: flavor, + target: target, + ); + case ReleaseType.ios: + return IosPatcher( + argResults: results, + flavor: flavor, + target: target, + ); + case ReleaseType.iosFramework: + return IosFrameworkPatcher( + argResults: results, + flavor: flavor, + target: target, + ); + case ReleaseType.aar: + return AarPatcher( + argResults: results, + flavor: flavor, + target: target, + ); + } + } + + bool get allowAssetDiffs => results['allow-asset-diffs'] == true; + bool get allowNativeDiffs => results['allow-native-diffs'] == true; + + String? lastBuiltFlutterRevision; + + @visibleForTesting + Future createPatch(Patcher patcher) async { + await patcher.assertPreconditions(); + await patcher.assertArgsAreValid(); + + await cache.updateAll(); + + final app = await codePushClientWrapper.getApp(appId: appId); + + File? patchArtifact; + final String releaseVersion; + if (results.wasParsed('release-version')) { + releaseVersion = results['release-version'] as String; + } else { + patchArtifact = await patcher.buildPatchArtifact(); + lastBuiltFlutterRevision = shorebirdEnv.flutterRevision; + releaseVersion = await patcher.extractReleaseVersionFromArtifact( + patchArtifact, + ); + } + + final release = await getRelease( + releaseVersion: releaseVersion, + patcher: patcher, + ); + final releaseArtifact = await downloadPrimaryReleaseArtifact( + release: release, + patcher: patcher, + ); + + final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith( + flutterRevisionOverride: release.flutterRevision, + ); + + return await runScoped( + () async { + await cache.updateAll(); + + // Don't built the patch artifact twice with the same Flutter revision. + if (lastBuiltFlutterRevision != release.flutterRevision) { + patchArtifact = await patcher.buildPatchArtifact(); + } + + final diffStatus = await assertUnpatchableDiffs( + releaseArtifact: releaseArtifact, + patchArtifact: patchArtifact!, + archiveDiffer: patcher.archiveDiffer, + ); + final patchArtifactBundles = await patcher.createPatchArtifacts( + appId: appId, + releaseId: release.id, + ); + + final dryRun = results['dry-run'] == true; + if (dryRun) { + logger + ..info('No issues detected.') + ..info('The server may enforce additional checks.'); + exit(ExitCode.success.code); + } + + await confirmCreatePatch( + app: app, + releaseVersion: releaseVersion, + patcher: patcher, + patchArtifactBundles: patchArtifactBundles, + ); + await codePushClientWrapper.publishPatch( + appId: appId, + releaseId: release.id, + metadata: await patcher.createPatchMetadata(diffStatus), + platform: patcher.releaseType.releasePlatform, + track: + isStaging ? DeploymentTrack.staging : DeploymentTrack.production, + patchArtifactBundles: patchArtifactBundles, + ); + }, + values: { + shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv), + }, + ); + } + + Future assertUnpatchableDiffs({ + required File releaseArtifact, + required File patchArtifact, + required ArchiveDiffer archiveDiffer, + }) async { + try { + return patchDiffChecker.confirmUnpatchableDiffsIfNecessary( + localArtifact: patchArtifact, + releaseArtifact: releaseArtifact, + archiveDiffer: archiveDiffer, + allowAssetChanges: allowAssetDiffs, + allowNativeChanges: allowNativeDiffs, + ); + } on UserCancelledException { + exit(ExitCode.success.code); + } on UnpatchableChangeException { + logger.info('Exiting.'); + exit(ExitCode.software.code); + } + } + + Future confirmCreatePatch({ + required AppMetadata app, + required String releaseVersion, + required Patcher patcher, + required Map patchArtifactBundles, + }) async { + final archMetadata = patchArtifactBundles.keys.map((arch) { + final size = formatBytes(patchArtifactBundles[arch]!.size); + return '${arch.name} ($size)'; + }); + final summary = [ + '''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''', + if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}', + 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', + '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''', + if (isStaging) + '🟠 Track: ${lightCyan.wrap('Staging')}' + else + 'đŸŸĸ Track: ${lightCyan.wrap('Production')}', + ]; + + logger.info( + ''' + +${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))} + +${summary.join('\n')} +''', + ); + + if (shorebirdEnv.canAcceptUserInput) { + final confirm = logger.confirm('Would you like to continue?'); + + if (!confirm) { + logger.info('Aborting.'); + exit(ExitCode.success.code); + } + } + } + + Future getRelease({ + required String releaseVersion, + required Patcher patcher, + }) async { + final release = await codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ); + + final releaseStatus = + release.platformStatuses[patcher.releaseType.releasePlatform]; + if (releaseStatus != ReleaseStatus.active) { + logger.err(''' +Release ${release.version} is in an incomplete state. It's possible that the original release was terminated or failed to complete. +Please re-run the release command for this version or create a new release.'''); + exit(ExitCode.software.code); + } + + return release; + } + + Future downloadPrimaryReleaseArtifact({ + required Release release, + required Patcher patcher, + }) async { + final artifact = await codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: patcher.primaryReleaseArtifactArch, + platform: patcher.releaseType.releasePlatform, + ); + + final downloadProgress = + logger.progress('Downloading ${patcher.primaryReleaseArtifactArch}'); + final File artifactFile; + try { + artifactFile = + await artifactManager.downloadFile(Uri.parse(artifact.url)); + } catch (e) { + downloadProgress.fail(e.toString()); + exit(ExitCode.software.code); + } + + downloadProgress.complete(); + return artifactFile; + } } diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart deleted file mode 100644 index 80f3a013..00000000 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart +++ /dev/null @@ -1,600 +0,0 @@ -import 'dart:async'; -import 'dart:io' hide Platform; - -import 'package:crypto/crypto.dart'; -import 'package:mason_logger/mason_logger.dart'; -import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; -import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/archive/directory_archive.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; -import 'package:shorebird_cli/src/artifact_manager.dart'; -import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/commands/commands.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/deployment_track.dart'; -import 'package:shorebird_cli/src/doctor.dart'; -import 'package:shorebird_cli/src/executables/executables.dart'; -import 'package:shorebird_cli/src/extensions/arg_results.dart'; -import 'package:shorebird_cli/src/formatters/file_size_formatter.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/shorebird_artifact_mixin.dart'; -import 'package:shorebird_cli/src/shorebird_artifacts.dart'; -import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; -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_cli/src/version.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; - -/// {@template patch_ios_command} -/// `shorebird patch ios` command. -/// {@endtemplate} -class PatchIosCommand extends ShorebirdCommand - with ShorebirdBuildMixin, ShorebirdArtifactMixin { - /// {@macro patch_ios_command} - PatchIosCommand({ - HashFunction? hashFn, - IosArchiveDiffer? archiveDiffer, - }) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()), - _archiveDiffer = archiveDiffer ?? IosArchiveDiffer() { - argParser - ..addOption( - 'target', - abbr: 't', - help: 'The main entrypoint file of the application.', - ) - ..addOption( - 'flavor', - help: 'The product flavor to use when building the app.', - ) - ..addOption( - 'release-version', - help: ''' -The version of the release being patched (e.g. "1.0.0+1"). - -If this option is not provided, the version number will be determined from the patch artifact.''', - ) - ..addFlag( - 'codesign', - help: 'Codesign the application bundle.', - defaultsTo: true, - ) - ..addOption( - exportOptionsPlistArgName, - help: - '''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.''', - ) - ..addFlag( - 'allow-native-diffs', - help: PatchCommand.allowNativeDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'allow-asset-diffs', - help: PatchCommand.allowAssetDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'dry-run', - abbr: 'n', - negatable: false, - help: 'Validate but do not upload the patch.', - ) - ..addFlag( - 'staging', - negatable: false, - help: 'Whether to publish the patch to the staging environment.', - ) - ..addFlag( - 'debug-linker', - defaultsTo: true, - help: 'Collects linker diagnostic information to help troubleshoot low ' - 'link percentages. File is saved to build/$_linkDebugInfoFileName.', - ); - } - - @override - String get name => 'ios'; - - @override - List get aliases => ['ios-alpha']; - - @override - String get description => - 'Publish new patches for a specific iOS release to Shorebird.'; - - final HashFunction _hashFn; - final IosArchiveDiffer _archiveDiffer; - - // Link percentage that is considered the minimum before a user might notice. - // Our early testing has shown that about: - // - 1/3rd of patches link at 99% - // - 1/3rd of patches link between 20% and 99% - // - 1/3rd of patches link below 20% - // Most lowering is likely due to: - // https://github.com/shorebirdtech/shorebird/issues/1825 - static const double minLinkPercentage = 75; - - static String lowLinkPercentageWarning(double linkPercentage) { - return ''' -${lightCyan.wrap('shorebird patch')} was only able to share ${linkPercentage.toStringAsFixed(1)}% of Dart code with the released app. -This means the patched code may execute slower than expected. -https://docs.shorebird.dev/status#link-percentage-ios -'''; - } - - @override - Future run() async { - try { - await shorebirdValidator.validatePreconditions( - checkShorebirdInitialized: true, - checkUserIsAuthenticated: true, - validators: doctor.iosCommandValidators, - supportedOperatingSystems: {Platform.macOS}, - ); - } on PreconditionFailedException catch (error) { - return error.exitCode.code; - } - - final allowAssetDiffs = results['allow-asset-diffs'] == true; - final allowNativeDiffs = results['allow-native-diffs'] == true; - final dryRun = results['dry-run'] == true; - final isStaging = results['staging'] == true; - - const arch = 'aarch64'; - const releasePlatform = ReleasePlatform.ios; - final flavor = results.findOption('flavor', argParser: argParser); - final target = results.findOption('target', argParser: argParser); - final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!; - final appId = shorebirdYaml.getAppId(flavor: flavor); - final app = await codePushClientWrapper.getApp(appId: appId); - var hasBuiltWithActiveFlutter = false; - - final File exportOptionsPlist; - try { - exportOptionsPlist = ios.exportOptionsPlistFromArgs(results); - } catch (error) { - logger.err('$error'); - return ExitCode.usage.code; - } - - final String releaseVersion; - final argReleaseVersion = results['release-version'] as String?; - if (argReleaseVersion != null) { - logger.detail('Using release version $argReleaseVersion from argument.'); - releaseVersion = argReleaseVersion; - } else { - logger.detail('No release version provided. Determining from archive.'); - try { - await _buildPatch( - exportOptionsPlist: exportOptionsPlist, - flavor: flavor, - target: target, - ); - } catch (_) { - return ExitCode.software.code; - } - hasBuiltWithActiveFlutter = true; - - try { - releaseVersion = _readVersionFromPlist(); - logger.info('Detected release version $releaseVersion'); - } on _ReadVersionException catch (error) { - logger.err(error.message); - return ExitCode.software.code; - } - } - - final release = await codePushClientWrapper.getRelease( - appId: appId, - releaseVersion: releaseVersion, - ); - - if (release.platformStatuses[ReleasePlatform.ios] == null) { - logger.err('No iOS release found for $releaseVersion.'); - return ExitCode.software.code; - } else if (release.platformStatuses[ReleasePlatform.ios] == - ReleaseStatus.draft) { - logger.err(''' -Release $releaseVersion is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''); - return ExitCode.software.code; - } - - final currentFlutterRevision = shorebirdEnv.flutterRevision; - if (release.flutterRevision != currentFlutterRevision) { - logger.info( - 'Release was built with Flutter revision ${release.flutterRevision}', - ); - } - - try { - await shorebirdFlutter.installRevision(revision: release.flutterRevision); - } catch (_) { - return ExitCode.software.code; - } - - final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith( - flutterRevisionOverride: release.flutterRevision, - ); - - return await runScoped( - () async { - if (!hasBuiltWithActiveFlutter || - release.flutterRevision != currentFlutterRevision) { - try { - await _buildPatch( - exportOptionsPlist: exportOptionsPlist, - flavor: flavor, - target: target, - ); - } catch (_) { - return ExitCode.software.code; - } - } - - final archivePath = getXcarchiveDirectory()?.path; - if (archivePath == null) { - logger.err('Unable to find .xcarchive directory'); - return ExitCode.software.code; - } - - final releaseArtifact = await codePushClientWrapper.getReleaseArtifact( - appId: appId, - releaseId: release.id, - arch: 'xcarchive', - platform: ReleasePlatform.ios, - ); - - final downloadProgress = - logger.progress('Downloading release artifact'); - final File releaseArtifactZipFile; - try { - releaseArtifactZipFile = await artifactManager.downloadFile( - Uri.parse(releaseArtifact.url), - ); - if (!releaseArtifactZipFile.existsSync()) { - throw Exception('Failed to download release artifact'); - } - } catch (error) { - downloadProgress.fail('$error'); - return ExitCode.software.code; - } - downloadProgress.complete(); - - final DiffStatus diffStatus; - try { - diffStatus = - await patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: Directory(archivePath), - releaseArtifact: releaseArtifactZipFile, - archiveDiffer: _archiveDiffer, - allowAssetChanges: allowAssetDiffs, - allowNativeChanges: allowNativeDiffs, - ); - } on UserCancelledException { - return ExitCode.success.code; - } on UnpatchableChangeException { - logger.info('Exiting.'); - return ExitCode.software.code; - } - - final unzipProgress = logger.progress('Extracting release artifact'); - final tempDir = Directory.systemTemp.createTempSync(); - await artifactManager.extractZip( - zipFile: releaseArtifactZipFile, - outputDirectory: tempDir, - ); - final releaseXcarchivePath = tempDir.path; - - unzipProgress.complete(); - final appDirectory = getAppDirectory( - xcarchiveDirectory: Directory(releaseXcarchivePath), - ); - if (appDirectory == null) { - logger.err('Unable to find release artifact .app directory'); - return ExitCode.software.code; - } - final releaseArtifactFile = File( - p.join( - appDirectory.path, - 'Frameworks', - 'App.framework', - 'App', - ), - ); - - double? percentLinked; - final useLinker = AotTools.usesLinker(release.flutterRevision); - if (useLinker) { - final (:exitCode, :linkPercentage) = await _runLinker( - releaseArtifact: releaseArtifactFile, - ); - - if (exitCode != ExitCode.success.code) return exitCode; - - if (linkPercentage != null && linkPercentage < minLinkPercentage) { - logger.warn(lowLinkPercentageWarning(linkPercentage)); - } - percentLinked = linkPercentage; - } - - if (dryRun) { - logger - ..info('No issues detected.') - ..info('The server may enforce additional checks.'); - return ExitCode.success.code; - } - - final patchBuildFile = - File(useLinker ? _vmcodeOutputPath : _aotOutputPath); - final File patchFile; - if (useLinker && await aotTools.isGeneratePatchDiffBaseSupported()) { - final patchBaseProgress = - logger.progress('Generating patch diff base'); - final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, - ); - - final File patchBaseFile; - try { - // If the aot_tools executable supports the dump_blobs command, we - // can generate a stable diff base and use that to create a patch. - patchBaseFile = await aotTools.generatePatchDiffBase( - analyzeSnapshotPath: analyzeSnapshotPath, - releaseSnapshot: releaseArtifactFile, - ); - patchBaseProgress.complete(); - } catch (error) { - patchBaseProgress.fail('$error'); - return ExitCode.software.code; - } - - patchFile = File( - await artifactManager.createDiff( - releaseArtifactPath: patchBaseFile.path, - patchArtifactPath: patchBuildFile.path, - ), - ); - } else { - patchFile = patchBuildFile; - } - - final patchFileSize = patchFile.statSync().size; - - final summary = [ - '''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('($appId)')}''', - if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}', - 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[$arch (${formatBytes(patchFileSize)})]')}''', - if (isStaging) - '🟠 Track: ${lightCyan.wrap('Staging')}' - else - 'đŸŸĸ Track: ${lightCyan.wrap('Production')}', - if (percentLinked != null) - '''🔗 Running ${lightCyan.wrap('${percentLinked.toStringAsFixed(1)}%')} on CPU''', - if (results['debug-linker'] == true && - (percentLinked != null && percentLinked < minLinkPercentage)) - '''🔍 Debug Info: ${lightCyan.wrap(_debugInfoOutputPath)}''', - ]; - - logger.info( - ''' - -${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))} - -${summary.join('\n')} -''', - ); - - if (shorebirdEnv.canAcceptUserInput) { - final confirm = logger.confirm('Would you like to continue?'); - - if (!confirm) { - logger.info('Aborting.'); - return ExitCode.success.code; - } - } - - await codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - track: - isStaging ? DeploymentTrack.staging : DeploymentTrack.production, - patchArtifactBundles: { - Arch.arm64: PatchArtifactBundle( - arch: arch, - path: patchFile.path, - hash: _hashFn(patchBuildFile.readAsBytesSync()), - size: patchFileSize, - ), - }, - metadata: CreatePatchMetadata( - releasePlatform: releasePlatform, - usedIgnoreAssetChangesFlag: allowAssetDiffs, - hasAssetChanges: diffStatus.hasAssetChanges, - usedIgnoreNativeChangesFlag: allowNativeDiffs, - hasNativeChanges: diffStatus.hasNativeChanges, - linkPercentage: percentLinked, - environment: BuildEnvironmentMetadata( - operatingSystem: platform.operatingSystem, - operatingSystemVersion: platform.operatingSystemVersion, - shorebirdVersion: packageVersion, - xcodeVersion: await xcodeBuild.version(), - ), - ), - ); - - return ExitCode.success.code; - }, - values: { - shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv), - }, - ); - } - - String get _buildDirectory => p.join( - shorebirdEnv.getShorebirdProjectRoot()!.path, - 'build', - ); - - String get _aotOutputPath => p.join( - _buildDirectory, - 'out.aot', - ); - - String get _vmcodeOutputPath => p.join( - _buildDirectory, - 'out.vmcode', - ); - - static const _linkDebugInfoFileName = 'linker_diagnostic.zip'; - String get _debugInfoOutputPath => p.join( - _buildDirectory, - _linkDebugInfoFileName, - ); - - String _readVersionFromPlist() { - final archivePath = getXcarchiveDirectory()?.path; - if (archivePath == null) { - throw _ReadVersionException('Unable to find .xcarchive directory'); - } - - final plistFile = File(p.join(archivePath, 'Info.plist')); - if (!plistFile.existsSync()) { - throw _ReadVersionException( - 'No Info.plist file found at ${plistFile.path}.', - ); - } - - final plist = Plist(file: plistFile); - try { - return plist.versionNumber; - } catch (error) { - throw _ReadVersionException( - 'Failed to determine release version from ${plistFile.path}: $error', - ); - } - } - - Future _buildPatch({ - required File exportOptionsPlist, - required String? flavor, - required String? target, - }) async { - final shouldCodesign = results['codesign'] == true; - final flutterVersionString = await shorebirdFlutter.getVersionAndRevision(); - final buildProgress = logger.progress( - 'Building patch with Flutter $flutterVersionString', - ); - try { - // If buildIpa is called with a different codesign value than the release - // was, we will erroneously report native diffs. - await buildIpa( - codesign: shouldCodesign, - exportOptionsPlist: exportOptionsPlist, - flavor: flavor, - target: target, - ); - } on ProcessException catch (error) { - buildProgress.fail('Failed to build: ${error.message}'); - rethrow; - } on BuildException catch (error) { - buildProgress.fail('Failed to build IPA'); - logger.err(error.message); - rethrow; - } - - try { - final newestDillFile = newestAppDill(); - await buildElfAotSnapshot( - appDillPath: newestDillFile.path, - outFilePath: _aotOutputPath, - ); - } catch (error) { - buildProgress.fail('$error'); - rethrow; - } - - buildProgress.complete(); - } - - Future<_LinkResult> _runLinker({ - required File releaseArtifact, - }) async { - final patch = File(_aotOutputPath); - final dumpDebugInfo = results['debug-linker'] == true && - (await aotTools.isLinkDebugInfoSupported()); - - if (!patch.existsSync()) { - logger.err('Unable to find patch AOT file at ${patch.path}'); - return (exitCode: ExitCode.software.code, linkPercentage: null); - } - - final analyzeSnapshot = File( - shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, - ), - ); - - if (!analyzeSnapshot.existsSync()) { - logger.err('Unable to find analyze_snapshot at ${analyzeSnapshot.path}'); - return (exitCode: ExitCode.software.code, linkPercentage: null); - } - - final genSnapshot = shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, - ); - - final linkProgress = logger.progress('Linking AOT files'); - double? linkPercentage; - try { - final dumpDebugInfoDir = - dumpDebugInfo ? Directory.systemTemp.createTempSync() : null; - - linkPercentage = await aotTools.link( - base: releaseArtifact.path, - patch: patch.path, - analyzeSnapshot: analyzeSnapshot.path, - genSnapshot: genSnapshot, - outputPath: _vmcodeOutputPath, - workingDirectory: _buildDirectory, - kernel: newestAppDill().path, - dumpDebugInfoPath: dumpDebugInfoDir?.path, - ); - - if (dumpDebugInfo && dumpDebugInfoDir != null) { - final debugInfoZip = await dumpDebugInfoDir.zipToTempFile(); - debugInfoZip.copySync( - p.join( - 'build', - _debugInfoOutputPath, - ), - ); - } - } catch (error) { - linkProgress.fail('Failed to link AOT files: $error'); - return (exitCode: ExitCode.software.code, linkPercentage: null); - } - linkProgress.complete(); - return (exitCode: ExitCode.success.code, linkPercentage: linkPercentage); - } -} - -typedef _LinkResult = ({int exitCode, double? linkPercentage}); - -/// {@template _ReadVersionException} -/// Exception thrown when the release version cannot be determined. -/// {@endtemplate} -class _ReadVersionException implements Exception { - /// {@macro _ReadVersionException} - _ReadVersionException(this.message); - - final String message; -} diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_ios_framework_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_ios_framework_command.dart deleted file mode 100644 index 7b22a99a..00000000 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_ios_framework_command.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'dart:io' hide Platform; - -import 'package:collection/collection.dart'; -import 'package:crypto/crypto.dart'; -import 'package:mason_logger/mason_logger.dart'; -import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; -import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; -import 'package:shorebird_cli/src/artifact_manager.dart'; -import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/commands/commands.dart'; -import 'package:shorebird_cli/src/config/shorebird_yaml.dart'; -import 'package:shorebird_cli/src/deployment_track.dart'; -import 'package:shorebird_cli/src/doctor.dart'; -import 'package:shorebird_cli/src/executables/executables.dart'; -import 'package:shorebird_cli/src/formatters/file_size_formatter.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/shorebird_artifact_mixin.dart'; -import 'package:shorebird_cli/src/shorebird_artifacts.dart'; -import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; -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_cli/src/version.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; - -class PatchIosFrameworkCommand extends ShorebirdCommand - with ShorebirdBuildMixin, ShorebirdArtifactMixin { - PatchIosFrameworkCommand({ - HashFunction? hashFn, - IosArchiveDiffer? archiveDiffer, - }) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()), - _archiveDiffer = archiveDiffer ?? IosArchiveDiffer() { - argParser - ..addOption( - 'release-version', - help: ''' -The version of the associated release (e.g. "1.0.0"). This should be the version -of the iOS app that is using this module.''', - ) - ..addFlag( - 'allow-native-diffs', - help: PatchCommand.allowNativeDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'allow-asset-diffs', - help: PatchCommand.allowAssetDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'dry-run', - abbr: 'n', - negatable: false, - help: 'Validate but do not upload the patch.', - ); - } - - final HashFunction _hashFn; - final IosArchiveDiffer _archiveDiffer; - - String get _buildDirectory => p.join( - shorebirdEnv.getShorebirdProjectRoot()!.path, - 'build', - ); - - String get _vmcodeOutputPath => p.join( - _buildDirectory, - 'out.vmcode', - ); - - @override - String get name => 'ios-framework'; - - @override - List get aliases => ['ios-framework-alpha']; - - @override - String get description => - 'Publish new patches for a specific iOS framework release to Shorebird.'; - - @override - Future run() async { - try { - await shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - validators: doctor.iosCommandValidators, - supportedOperatingSystems: {Platform.macOS}, - ); - } on PreconditionFailedException catch (e) { - return e.exitCode.code; - } - - final allowAssetDiffs = results['allow-asset-diffs'] == true; - final allowNativeDiffs = results['allow-native-diffs'] == true; - final dryRun = results['dry-run'] == true; - - const arch = 'aarch64'; - const releasePlatform = ReleasePlatform.ios; - final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!; - final appId = shorebirdYaml.getAppId(); - final app = await codePushClientWrapper.getApp(appId: appId); - final releases = await codePushClientWrapper.getReleases(appId: appId); - - if (releases.isEmpty) { - logger.info('No releases found'); - return ExitCode.success.code; - } - - final releaseVersion = results['release-version'] as String? ?? - await _promptForReleaseVersion(releases); - - final release = releases.firstWhereOrNull( - (r) => r.version == releaseVersion, - ); - - if (releaseVersion == null || release == null) { - logger.info(''' -No release found for version $releaseVersion - -Available release versions: -${releases.map((r) => r.version).join('\n')}'''); - return ExitCode.success.code; - } - - if (release.platformStatuses[ReleasePlatform.ios] == ReleaseStatus.draft) { - logger.err(''' -Release $releaseVersion is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''); - return ExitCode.software.code; - } - - try { - await shorebirdFlutter.installRevision(revision: release.flutterRevision); - } catch (_) { - return ExitCode.software.code; - } - - final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith( - flutterRevisionOverride: release.flutterRevision, - ); - - return await runScoped( - () async { - final flutterVersionString = - await shorebirdFlutter.getVersionAndRevision(); - final buildProgress = logger.progress( - 'Building patch with Flutter $flutterVersionString', - ); - try { - await buildIosFramework(); - buildProgress.complete(); - } on ProcessException catch (error) { - buildProgress.fail('Failed to build: ${error.message}'); - return ExitCode.software.code; - } - - final File aotSnapshotFile; - try { - final newestDillFile = newestAppDill(); - aotSnapshotFile = await buildElfAotSnapshot( - appDillPath: newestDillFile.path, - outFilePath: p.join( - shorebirdEnv.getShorebirdProjectRoot()!.path, - 'build', - 'out.aot', - ), - ); - } catch (error) { - buildProgress.fail('$error'); - return ExitCode.software.code; - } - - buildProgress.complete(); - - final releaseArtifact = await codePushClientWrapper.getReleaseArtifact( - appId: appId, - releaseId: release.id, - arch: 'xcframework', - platform: ReleasePlatform.ios, - ); - - final downloadProgress = - logger.progress('Downloading release artifact'); - final File releaseArtifactZipFile; - try { - releaseArtifactZipFile = await artifactManager.downloadFile( - Uri.parse(releaseArtifact.url), - ); - if (!releaseArtifactZipFile.existsSync()) { - throw Exception('Failed to download release artifact'); - } - } catch (error) { - downloadProgress.fail('$error'); - return ExitCode.software.code; - } - downloadProgress.complete(); - - final DiffStatus diffStatus; - try { - diffStatus = - await patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: Directory(getAppXcframeworkPath()), - releaseArtifact: releaseArtifactZipFile, - archiveDiffer: _archiveDiffer, - allowAssetChanges: allowAssetDiffs, - allowNativeChanges: allowNativeDiffs, - ); - } on UserCancelledException { - return ExitCode.success.code; - } on UnpatchableChangeException { - logger.info('Exiting.'); - return ExitCode.software.code; - } - - final unzipProgress = logger.progress('Extracting release artifact'); - final tempDir = Directory.systemTemp.createTempSync(); - await artifactManager.extractZip( - zipFile: releaseArtifactZipFile, - outputDirectory: tempDir, - ); - final releaseXcframeworkPath = tempDir.path; - - unzipProgress - .complete('Extracted release artifact to $releaseXcframeworkPath'); - final releaseArtifactFile = File( - p.join( - releaseXcframeworkPath, - 'ios-arm64', - 'App.framework', - 'App', - ), - ); - - final useLinker = AotTools.usesLinker(release.flutterRevision); - if (useLinker) { - final exitCode = await _runLinker( - aotSnapshot: aotSnapshotFile, - releaseArtifact: releaseArtifactFile, - ); - if (exitCode != ExitCode.success.code) { - return exitCode; - } - } - - final patchBuildFile = - useLinker ? File(_vmcodeOutputPath) : aotSnapshotFile; - final File patchFile; - if (await aotTools.isGeneratePatchDiffBaseSupported()) { - final patchBaseProgress = - logger.progress('Generating patch diff base'); - final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, - ); - - final File patchBaseFile; - try { - // If the aot_tools executable supports the dump_blobs command, we - // can generate a stable diff base and use that to create a patch. - patchBaseFile = await aotTools.generatePatchDiffBase( - analyzeSnapshotPath: analyzeSnapshotPath, - releaseSnapshot: releaseArtifactFile, - ); - patchBaseProgress.complete(); - } catch (error) { - patchBaseProgress.fail('$error'); - return ExitCode.software.code; - } - - patchFile = File( - await artifactManager.createDiff( - releaseArtifactPath: patchBaseFile.path, - patchArtifactPath: patchBuildFile.path, - ), - ); - } else { - patchFile = patchBuildFile; - } - - if (dryRun) { - logger - ..info('No issues detected.') - ..info('The server may enforce additional checks.'); - return ExitCode.success.code; - } - - final patchFileSize = patchFile.statSync().size; - final summary = [ - '''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('($appId)')}''', - 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[$arch (${formatBytes(patchFileSize)})]')}''', - 'đŸŸĸ Track: ${lightCyan.wrap('Production')}', - ]; - - logger.info( - ''' - -${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))} - -${summary.join('\n')} -''', - ); - - if (shorebirdEnv.canAcceptUserInput) { - final confirm = logger.confirm('Would you like to continue?'); - - if (!confirm) { - logger.info('Aborting.'); - return ExitCode.success.code; - } - } - - await codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - track: DeploymentTrack.production, - patchArtifactBundles: { - Arch.arm64: PatchArtifactBundle( - arch: arch, - path: patchFile.path, - hash: _hashFn(patchBuildFile.readAsBytesSync()), - size: patchFileSize, - ), - }, - metadata: CreatePatchMetadata( - releasePlatform: releasePlatform, - usedIgnoreAssetChangesFlag: allowAssetDiffs, - hasAssetChanges: diffStatus.hasAssetChanges, - usedIgnoreNativeChangesFlag: allowNativeDiffs, - hasNativeChanges: diffStatus.hasNativeChanges, - linkPercentage: null, - environment: BuildEnvironmentMetadata( - operatingSystem: platform.operatingSystem, - operatingSystemVersion: platform.operatingSystemVersion, - shorebirdVersion: packageVersion, - xcodeVersion: await xcodeBuild.version(), - ), - ), - ); - - return ExitCode.success.code; - }, - values: { - shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv), - }, - ); - } - - Future _promptForReleaseVersion(List releases) async { - if (releases.isEmpty) return null; - final release = logger.chooseOne( - 'Which release would you like to patch?', - choices: releases, - display: (release) => release.version, - ); - return release.version; - } - - Future _runLinker({ - required File aotSnapshot, - required File releaseArtifact, - }) async { - if (!aotSnapshot.existsSync()) { - logger.err('Unable to find patch AOT file at ${aotSnapshot.path}'); - return ExitCode.software.code; - } - - final analyzeSnapshot = File( - shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, - ), - ); - - if (!analyzeSnapshot.existsSync()) { - logger.err('Unable to find analyze_snapshot at ${analyzeSnapshot.path}'); - return ExitCode.software.code; - } - - final genSnapshot = shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, - ); - - final linkProgress = logger.progress('Linking AOT files'); - try { - await aotTools.link( - base: releaseArtifact.path, - patch: aotSnapshot.path, - analyzeSnapshot: analyzeSnapshot.path, - genSnapshot: genSnapshot, - kernel: newestAppDill().path, - outputPath: _vmcodeOutputPath, - workingDirectory: _buildDirectory, - ); - } catch (error) { - linkProgress.fail('Failed to link AOT files: $error'); - return ExitCode.software.code; - } - - linkProgress.complete(); - return ExitCode.success.code; - } -} diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/patcher.dart similarity index 100% rename from packages/shorebird_cli/lib/src/commands/patch_new/patcher.dart rename to packages/shorebird_cli/lib/src/commands/patch/patcher.dart diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/patch_new.dart b/packages/shorebird_cli/lib/src/commands/patch_new/patch_new.dart deleted file mode 100644 index f363b0ba..00000000 --- a/packages/shorebird_cli/lib/src/commands/patch_new/patch_new.dart +++ /dev/null @@ -1,6 +0,0 @@ -export 'aar_patcher.dart'; -export 'android_patcher.dart'; -export 'ios_framework_patcher.dart'; -export 'ios_patcher.dart'; -export 'patch_new_command.dart'; -export 'patcher.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/patch_new_command.dart b/packages/shorebird_cli/lib/src/commands/patch_new/patch_new_command.dart deleted file mode 100644 index 37d3992c..00000000 --- a/packages/shorebird_cli/lib/src/commands/patch_new/patch_new_command.dart +++ /dev/null @@ -1,354 +0,0 @@ -import 'package:mason_logger/mason_logger.dart'; -import 'package:meta/meta.dart'; -import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart'; -import 'package:shorebird_cli/src/artifact_manager.dart'; -import 'package:shorebird_cli/src/cache.dart'; -import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/deployment_track.dart'; -import 'package:shorebird_cli/src/extensions/arg_results.dart'; -import 'package:shorebird_cli/src/formatters/formatters.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/release_type.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; - -typedef ResolvePatcher = Patcher Function(ReleaseType releaseType); - -class PatchNewCommand extends ShorebirdCommand { - PatchNewCommand({ - ResolvePatcher? resolvePatcher, - }) { - _resolvePatcher = resolvePatcher ?? getPatcher; - argParser - ..addMultiOption( - 'platform', - abbr: 'p', - help: 'The platform(s) to to build this release for.', - allowed: ReleaseType.values.map((e) => e.cliName).toList(), - // TODO(bryanoltman): uncomment this once https://github.com/dart-lang/args/pull/273 lands - // mandatory: true. - ) - ..addOption( - 'build-number', - help: ''' -An identifier used as an internal version number. -Each build must have a unique identifier to differentiate it from previous builds. -It is used to determine whether one build is more recent than another, with higher numbers indicating more recent build. -On Android it is used as "versionCode". -On Xcode builds it is used as "CFBundleVersion".''', - defaultsTo: '1.0', - ) - ..addOption( - 'target', - abbr: 't', - help: 'The main entrypoint file of the application.', - ) - ..addOption( - 'flavor', - help: 'The product flavor to use when building the app.', - ) - ..addOption( - 'release-version', - help: ''' -The version of the associated release (e.g. "1.0.0"). This should be the version -of the iOS app that is using this module.''', - ) - ..addFlag( - 'allow-native-diffs', - help: allowNativeDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'allow-asset-diffs', - help: allowAssetDiffsHelpText, - negatable: false, - ) - ..addFlag( - 'staging', - negatable: false, - help: 'Whether to publish the patch to the staging environment.', - ) - ..addFlag( - 'dry-run', - abbr: 'n', - negatable: false, - help: 'Validate but do not upload the patch.', - ); - } - - static final allowNativeDiffsHelpText = ''' -Patch even if native code diffs are detected. -NOTE: this is ${styleBold.wrap('not')} recommended. Native code changes cannot be included in a patch and attempting to do so can cause your app to crash or behave unexpectedly.'''; - - static final allowAssetDiffsHelpText = ''' -Patch even if asset diffs are detected. -NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be included in a patch can cause your app to behave unexpectedly.'''; - - late final ResolvePatcher _resolvePatcher; - - @override - bool get hidden => true; - - @override - String get description => - 'Creates a shorebird patch for the provided target platforms'; - - @override - String get name => 'patch-new'; - - /// The shorebird app ID for the current project. - String get appId => shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); - - /// The build flavor, if provided. - late String? flavor = results.findOption('flavor', argParser: argParser); - - /// The target script, if provided. - late String? target = results.findOption('target', argParser: argParser); - - bool get isStaging => results['staging'] == true; - - @override - Future run() async { - final patcherFutures = - results.releaseTypes.map(_resolvePatcher).map(createPatch); - - for (final patcherFuture in patcherFutures) { - await patcherFuture; - } - - return ExitCode.success.code; - } - - @visibleForTesting - Patcher getPatcher(ReleaseType releaseType) { - switch (releaseType) { - case ReleaseType.android: - return AndroidPatcher( - argResults: results, - flavor: flavor, - target: target, - ); - case ReleaseType.ios: - return IosPatcher( - argResults: results, - flavor: flavor, - target: target, - ); - case ReleaseType.iosFramework: - return IosFrameworkPatcher( - argResults: results, - flavor: flavor, - target: target, - ); - case ReleaseType.aar: - return AarPatcher( - argResults: results, - flavor: flavor, - target: target, - ); - } - } - - bool get allowAssetDiffs => results['allow-asset-diffs'] == true; - bool get allowNativeDiffs => results['allow-native-diffs'] == true; - - String? lastBuiltFlutterRevision; - - @visibleForTesting - Future createPatch(Patcher patcher) async { - await patcher.assertPreconditions(); - await patcher.assertArgsAreValid(); - - await cache.updateAll(); - - final app = await codePushClientWrapper.getApp(appId: appId); - - File? patchArtifact; - final String releaseVersion; - if (results.wasParsed('release-version')) { - releaseVersion = results['release-version'] as String; - } else { - patchArtifact = await patcher.buildPatchArtifact(); - lastBuiltFlutterRevision = shorebirdEnv.flutterRevision; - releaseVersion = await patcher.extractReleaseVersionFromArtifact( - patchArtifact, - ); - } - - final release = await getRelease( - releaseVersion: releaseVersion, - patcher: patcher, - ); - final releaseArtifact = await downloadPrimaryReleaseArtifact( - release: release, - patcher: patcher, - ); - - final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith( - flutterRevisionOverride: release.flutterRevision, - ); - - return await runScoped( - () async { - await cache.updateAll(); - - // Don't built the patch artifact twice with the same Flutter revision. - if (lastBuiltFlutterRevision != release.flutterRevision) { - patchArtifact = await patcher.buildPatchArtifact(); - } - - final diffStatus = await assertUnpatchableDiffs( - releaseArtifact: releaseArtifact, - patchArtifact: patchArtifact!, - archiveDiffer: patcher.archiveDiffer, - ); - final patchArtifactBundles = await patcher.createPatchArtifacts( - appId: appId, - releaseId: release.id, - ); - - final dryRun = results['dry-run'] == true; - if (dryRun) { - logger - ..info('No issues detected.') - ..info('The server may enforce additional checks.'); - exit(ExitCode.success.code); - } - - await confirmCreatePatch( - app: app, - releaseVersion: releaseVersion, - patcher: patcher, - patchArtifactBundles: patchArtifactBundles, - ); - await codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - metadata: await patcher.createPatchMetadata(diffStatus), - platform: patcher.releaseType.releasePlatform, - track: - isStaging ? DeploymentTrack.staging : DeploymentTrack.production, - patchArtifactBundles: patchArtifactBundles, - ); - }, - values: { - shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv), - }, - ); - } - - Future assertUnpatchableDiffs({ - required File releaseArtifact, - required File patchArtifact, - required ArchiveDiffer archiveDiffer, - }) async { - try { - return patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: patchArtifact, - releaseArtifact: releaseArtifact, - archiveDiffer: archiveDiffer, - allowAssetChanges: allowAssetDiffs, - allowNativeChanges: allowNativeDiffs, - ); - } on UserCancelledException { - exit(ExitCode.success.code); - } on UnpatchableChangeException { - logger.info('Exiting.'); - exit(ExitCode.software.code); - } - } - - Future confirmCreatePatch({ - required AppMetadata app, - required String releaseVersion, - required Patcher patcher, - required Map patchArtifactBundles, - }) async { - final archMetadata = patchArtifactBundles.keys.map((arch) { - final size = formatBytes(patchArtifactBundles[arch]!.size); - return '${arch.name} ($size)'; - }); - final summary = [ - '''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''', - if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}', - 'đŸ“Ļ Release Version: ${lightCyan.wrap(releaseVersion)}', - '''đŸ•šī¸ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''', - if (isStaging) - '🟠 Track: ${lightCyan.wrap('Staging')}' - else - 'đŸŸĸ Track: ${lightCyan.wrap('Production')}', - ]; - - logger.info( - ''' - -${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))} - -${summary.join('\n')} -''', - ); - - if (shorebirdEnv.canAcceptUserInput) { - final confirm = logger.confirm('Would you like to continue?'); - - if (!confirm) { - logger.info('Aborting.'); - exit(ExitCode.success.code); - } - } - } - - Future getRelease({ - required String releaseVersion, - required Patcher patcher, - }) async { - final release = await codePushClientWrapper.getRelease( - appId: appId, - releaseVersion: releaseVersion, - ); - - final releaseStatus = - release.platformStatuses[patcher.releaseType.releasePlatform]; - if (releaseStatus != ReleaseStatus.active) { - logger.err(''' -Release ${release.version} is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''); - exit(ExitCode.software.code); - } - - return release; - } - - Future downloadPrimaryReleaseArtifact({ - required Release release, - required Patcher patcher, - }) async { - final artifact = await codePushClientWrapper.getReleaseArtifact( - appId: appId, - releaseId: release.id, - arch: patcher.primaryReleaseArtifactArch, - platform: patcher.releaseType.releasePlatform, - ); - - final downloadProgress = - logger.progress('Downloading ${patcher.primaryReleaseArtifactArch}'); - final File artifactFile; - try { - artifactFile = - await artifactManager.downloadFile(Uri.parse(artifact.url)); - } catch (e) { - downloadProgress.fail(e.toString()); - exit(ExitCode.software.code); - } - - downloadProgress.complete(); - return artifactFile; - } -} diff --git a/packages/shorebird_cli/lib/src/commands/release/release_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_command.dart index 1434402f..1d9b0270 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -49,7 +49,7 @@ On Xcode builds it is used as "CFBundleVersion".''', ) ..addFlag( 'codesign', - help: 'Codesign the application bundle.', + help: 'Codesign the application bundle (iOS only).', defaultsTo: true, ) ..addFlag( @@ -61,7 +61,7 @@ On Xcode builds it is used as "CFBundleVersion".''', ..addOption( exportOptionsPlistArgName, help: - '''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.''', + '''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys (iOS only).''', ) ..addOption( 'flutter-version', @@ -102,9 +102,6 @@ of the iOS app that is using this module.''', late final ResolveReleaser _resolveReleaser; - @override - bool get hidden => true; - @override String get description => 'Creates a shorebird release for the provided target platforms'; diff --git a/packages/shorebird_cli/lib/src/shorebird_artifact_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_artifact_mixin.dart deleted file mode 100644 index 97fb2ddc..00000000 --- a/packages/shorebird_cli/lib/src/shorebird_artifact_mixin.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:path/path.dart' as p; -import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; - -mixin ShorebirdArtifactMixin on ShorebirdCommand { - String get aarLibraryPath { - final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; - return p.joinAll([ - projectRoot.path, - 'build', - 'host', - 'outputs', - 'repo', - ]); - } - - String aarArtifactDirectory({ - required String packageName, - required String buildNumber, - }) => - p.joinAll([ - aarLibraryPath, - ...packageName.split('.'), - 'flutter_release', - buildNumber, - ]); - - String aarArtifactPath({ - required String packageName, - required String buildNumber, - }) => - p.join( - aarArtifactDirectory( - packageName: packageName, - buildNumber: buildNumber, - ), - 'flutter_release-$buildNumber.aar', - ); - - Future extractAar({ - required String packageName, - required String buildNumber, - required UnzipFn unzipFn, - }) async { - final aarDirectory = aarArtifactDirectory( - packageName: packageName, - buildNumber: buildNumber, - ); - final aarPath = aarArtifactPath( - packageName: packageName, - buildNumber: buildNumber, - ); - - final zipDir = Directory.systemTemp.createTempSync(); - final zipPath = p.join(zipDir.path, 'flutter_release-$buildNumber.zip'); - logger.detail('Extracting $aarPath to $zipPath'); - - // Copy the .aar file to a .zip file so package:archive knows how to read it - File(aarPath).copySync(zipPath); - final extractedZipDir = p.join( - aarDirectory, - 'flutter_release-$buildNumber', - ); - // Unzip the .zip file to a directory so we can read the .so files - await unzipFn(zipPath, extractedZipDir); - return extractedZipDir; - } - - /// Returns the .xcarchive directory generated by `flutter build ipa`. This - /// was traditionally named `Runner.xcarchive`, but can now be renamed. - Directory? getXcarchiveDirectory() { - final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; - final archiveDirectory = Directory( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - ), - ); - - if (!archiveDirectory.existsSync()) return null; - - return archiveDirectory - .listSync() - .whereType() - .firstWhereOrNull((directory) => directory.path.endsWith('.xcarchive')); - } - - /// Returns the .app directory generated by `flutter build ipa`. This was - /// traditionally named `Runner.app`, but can now be renamed. - Directory? getAppDirectory({required Directory xcarchiveDirectory}) { - final applicationsDirectory = Directory( - p.join( - xcarchiveDirectory.path, - 'Products', - 'Applications', - ), - ); - - if (!applicationsDirectory.existsSync()) { - return null; - } - - return applicationsDirectory - .listSync() - .whereType() - .firstWhereOrNull((directory) => directory.path.endsWith('.app')); - } - - static const String appXcframeworkName = 'App.xcframework'; - - /// Returns the path to the App.xcframework generated by - /// `shorebird release ios-framework` or - /// `shorebird patch ios-framework`. - String getAppXcframeworkPath() { - return p.join(getAppXcframeworkDirectory().path, appXcframeworkName); - } - - /// Returns the [Directory] containing the App.xcframework generated by - /// `shorebird release ios-framework` or - /// `shorebird patch ios-framework`. - Directory getAppXcframeworkDirectory() { - final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; - return Directory( - p.join( - projectRoot.path, - 'build', - 'ios', - 'framework', - 'Release', - ), - ); - } - - /// Finds the most recently-edited app.dill file in the .dart_tool directory. - // TODO(bryanoltman): This is an enormous hack – we don't know that this is - // the correct file. - File newestAppDill() { - final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; - final dartToolBuildDir = Directory( - p.join( - projectRoot.path, - '.dart_tool', - 'flutter_build', - ), - ); - - return dartToolBuildDir - .listSync(recursive: true) - .whereType() - .where((f) => p.basename(f.path) == 'app.dill') - .reduce( - (a, b) => - a.statSync().modified.isAfter(b.statSync().modified) ? a : b, - ); - } -} diff --git a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart deleted file mode 100644 index 428b5949..00000000 --- a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart +++ /dev/null @@ -1,348 +0,0 @@ -import 'dart:io'; - -import 'package:mason_logger/mason_logger.dart'; -import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/os/operating_system_interface.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/shorebird_android_artifacts.dart'; -import 'package:shorebird_cli/src/shorebird_artifacts.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_process.dart'; - -// coverage:ignore-start -/// Used to wrap code that invokes `flutter build` with Shorebird's fork of -/// Flutter. -typedef ShorebirdBuildCommand = Future Function(); - -/// {@template build_exception} -/// Thrown when a build fails. -/// {@endtemplate} -class BuildException implements Exception { - /// {@macro build_exception} - BuildException(this.message); - - /// Information about the build failure. - final String message; -} - -mixin ShorebirdBuildMixin on ShorebirdCommand { - Future buildAppBundle({ - String? flavor, - String? target, - Iterable? targetPlatforms, - }) async { - await _runShorebirdBuildCommand(() async { - const executable = 'flutter'; - final targetPlatformArgs = targetPlatforms?.targetPlatformArg; - final arguments = [ - 'build', - 'appbundle', - '--release', - if (flavor != null) '--flavor=$flavor', - if (target != null) '--target=$target', - if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs', - ...results.rest, - ]; - - final result = await process.run( - executable, - arguments, - runInShell: true, - ); - - if (result.exitCode != ExitCode.success.code) { - throw BuildException( - 'Failed to build: ${result.stderr}', - ); - } - }); - - final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; - try { - return shorebirdAndroidArtifacts.findAab( - project: projectRoot, - flavor: flavor, - ); - } on MultipleArtifactsFoundException catch (error) { - throw BuildException( - 'Build succeeded, but it generated multiple AABs in the ' - 'build directory. ${error.foundArtifacts.map((e) => e.path)}', - ); - } on ArtifactNotFoundException catch (error) { - throw BuildException( - 'Build succeeded, but could not find the AAB in the build directory. ' - 'Expected to find ${error.artifactName}', - ); - } - } - - Future buildAar({ - required String buildNumber, - Iterable? targetPlatforms, - }) async { - return _runShorebirdBuildCommand(() async { - const executable = 'flutter'; - final targetPlatformArgs = targetPlatforms?.targetPlatformArg; - final arguments = [ - 'build', - 'aar', - '--no-debug', - '--no-profile', - '--build-number=$buildNumber', - if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs', - ...results.rest, - ]; - - final result = await process.run( - executable, - arguments, - runInShell: true, - ); - - if (result.exitCode != ExitCode.success.code) { - throw ProcessException( - 'flutter', - arguments, - result.stderr.toString(), - result.exitCode, - ); - } - }); - } - - Future buildApk({ - String? flavor, - String? target, - Iterable? targetPlatforms, - bool splitPerAbi = false, - }) async { - await _runShorebirdBuildCommand(() async { - const executable = 'flutter'; - final targetPlatformArgs = targetPlatforms?.targetPlatformArg; - final arguments = [ - 'build', - 'apk', - '--release', - if (flavor != null) '--flavor=$flavor', - if (target != null) '--target=$target', - if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs', - // TODO(bryanoltman): reintroduce coverage when we can support this. - // See https://github.com/shorebirdtech/shorebird/issues/1141. - if (splitPerAbi) '--split-per-abi', - ...results.rest, - ]; - - final result = await process.run( - executable, - arguments, - runInShell: true, - ); - - if (result.exitCode != ExitCode.success.code) { - throw BuildException( - 'Failed to build: ${result.stderr}', - ); - } - }); - final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; - try { - return shorebirdAndroidArtifacts.findApk( - project: projectRoot, - flavor: flavor, - ); - } on MultipleArtifactsFoundException catch (error) { - throw BuildException( - 'Build succeeded, but it generated multiple APKs in the ' - 'build directory. ${error.foundArtifacts.map((e) => e.path)}', - ); - } on ArtifactNotFoundException catch (error) { - throw BuildException( - 'Build succeeded, but could not find the APK in the build directory. ' - 'Expected to find ${error.artifactName}', - ); - } - } - - /// Calls `flutter build ipa`. If [codesign] is false, this will only build - /// an .xcarchive and _not_ an .ipa. - Future buildIpa({ - required bool codesign, - File? exportOptionsPlist, - String? flavor, - String? target, - }) async { - return _runShorebirdBuildCommand(() async { - const executable = 'flutter'; - final exportOptionsPlistPath = - (exportOptionsPlist ?? ios.createExportOptionsPlist()).path; - final arguments = [ - 'build', - 'ipa', - '--release', - if (flavor != null) '--flavor=$flavor', - if (target != null) '--target=$target', - if (!codesign) '--no-codesign', - if (codesign) '''--export-options-plist=$exportOptionsPlistPath''', - ...results.rest, - ]; - - final result = await process.run( - executable, - arguments, - runInShell: true, - ); - - if (result.exitCode != ExitCode.success.code) { - throw ProcessException( - 'flutter', - arguments, - result.stderr.toString(), - result.exitCode, - ); - } - - if (result.stderr - .toString() - .contains('Encountered error while creating the IPA')) { - final errorMessage = _failedToCreateIpaErrorMessage( - stderr: result.stderr.toString(), - ); - - throw BuildException(errorMessage); - } - }); - } - - /// Builds a release iOS framework (.xcframework) for the current project. - Future buildIosFramework() async { - return _runShorebirdBuildCommand(() async { - const executable = 'flutter'; - final arguments = [ - 'build', - 'ios-framework', - '--no-debug', - '--no-profile', - ...results.rest, - ]; - - final result = await process.run( - executable, - arguments, - runInShell: true, - ); - - if (result.exitCode != ExitCode.success.code) { - throw ProcessException( - 'flutter', - arguments, - result.stderr.toString(), - result.exitCode, - ); - } - }); - } - - /// A wrapper around [command] (which runs a `flutter build` command with - /// Shorebird's fork of Flutter) with a try/finally that runs - /// `flutter pub get` with the system installation of Flutter to reset - /// `.dart_tool/package_config.json` to the system Flutter. - Future _runShorebirdBuildCommand(ShorebirdBuildCommand command) async { - try { - await command(); - } finally { - await _systemFlutterPubGet(); - } - } - - /// This is a hack to reset `.dart_tool/package_config.json` to point to the - /// Flutter SDK on the user's PATH. This is necessary because Flutter commands - /// run by shorebird update the package_config.json file to point to - /// shorebird's version of Flutter, which confuses VS Code. See - /// https://github.com/shorebirdtech/shorebird/issues/1101 for more info. - Future _systemFlutterPubGet() async { - const executable = 'flutter'; - if (osInterface.which(executable) == null) { - // If the user doesn't have Flutter on their PATH, then we can't run - // `flutter pub get` with the system Flutter. - return; - } - - final arguments = ['--no-version-check', 'pub', 'get', '--offline']; - - final result = await process.run( - executable, - arguments, - runInShell: true, - useVendedFlutter: false, - ); - - if (result.exitCode != ExitCode.success.code) { - logger.warn( - ''' -Build was successful, but `flutter pub get` failed to run after the build completed. You may see unexpected behavior in VS Code. - -Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.parse('https://docs.shorebird.dev/troubleshooting#i-installed-shorebird-and-now-i-cant-run-my-app-in-vs-code'))}. -''', - ); - } - } - - String _failedToCreateIpaErrorMessage({required String stderr}) { - // The full error text consists of many repeated lines of the format: - // (newlines added for line length) - // - // [ +1 ms] Encountered error while creating the IPA: - // [ ] error: exportArchive: Team "Team" does not have permission to - // create "iOS In House" provisioning profiles. - // error: exportArchive: No profiles for 'com.example.dev' were found - // error: exportArchive: No signing certificate "iOS Distribution" found - // error: exportArchive: Communication with Apple failed - // error: exportArchive: No signing certificate "iOS Distribution" found - // error: exportArchive: Team "My Team" does not have permission to - // create "iOS App Store" provisioning profiles. - // error: exportArchive: No profiles for 'com.example.demo' were found - // error: exportArchive: Communication with Apple failed - // error: exportArchive: No signing certificate "iOS Distribution" found - // error: exportArchive: Communication with Apple failed - final exportArchiveRegex = RegExp(r'error: exportArchive: (.+)$'); - - return stderr - .split('\n') - .map((l) => l.trim()) - .toSet() - .map(exportArchiveRegex.firstMatch) - .whereType() - .map((m) => ' ${m.group(1)!}') - .join('\n'); - } - - /// Creates an AOT snapshot of the given [appDillPath] at [outFilePath] and - /// returns the resulting file. - Future buildElfAotSnapshot({ - required String appDillPath, - required String outFilePath, - }) async { - final arguments = [ - '--deterministic', - '--snapshot-kind=app-aot-elf', - '--elf=$outFilePath', - appDillPath, - ]; - - final result = await process.run( - shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, - ), - arguments, - ); - - if (result.exitCode != ExitCode.success.code) { - throw Exception('Failed to create snapshot: ${result.stderr}'); - } - - return File(outFilePath); - } -} - -// coverage:ignore-end diff --git a/packages/shorebird_cli/lib/src/shorebird_release_version_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_release_version_mixin.dart deleted file mode 100644 index d8e4695a..00000000 --- a/packages/shorebird_cli/lib/src/shorebird_release_version_mixin.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:shorebird_cli/src/cache.dart'; -import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/executables/executables.dart'; - -/// Mixin on [ShorebirdCommand] which exposes methods -/// to extract the release version from an app bundle. -mixin ShorebirdReleaseVersionMixin on ShorebirdCommand { - /// Extract the release version from an appbundle. - Future extractReleaseVersionFromAppBundle( - String appBundlePath, - ) async { - await cache.updateAll(); - - final results = await Future.wait([ - bundletool.getVersionName(appBundlePath), - bundletool.getVersionCode(appBundlePath), - ]); - - final versionName = results[0]; - final versionCode = results[1]; - return '$versionName+$versionCode'; - } -} diff --git a/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart b/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart index 31e4645d..514cf34c 100644 --- a/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart @@ -5,13 +5,13 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:scoped/scoped.dart'; +import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/commands/build/build.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/os/operating_system_interface.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_cli/src/validators/validators.dart'; import 'package:test/test.dart'; @@ -22,27 +22,25 @@ import '../../mocks.dart'; void main() { group(BuildIpaCommand, () { late ArgResults argResults; + late ArtifactBuilder artifactBuilder; late Doctor doctor; late Ios ios; late ShorebirdLogger logger; late OperatingSystemInterface operatingSystemInterface; - late ShorebirdProcessResult buildProcessResult; - late ShorebirdProcessResult flutterPubGetProcessResult; late BuildIpaCommand command; late ShorebirdEnv shorebirdEnv; late ShorebirdFlutterValidator flutterValidator; - late ShorebirdProcess shorebirdProcess; late ShorebirdValidator shorebirdValidator; R runWithOverrides(R Function() body) { return runScoped( body, values: { + artifactBuilderRef.overrideWith(() => artifactBuilder), doctorRef.overrideWith(() => doctor), iosRef.overrideWith(() => ios), loggerRef.overrideWith(() => logger), osInterfaceRef.overrideWith(() => operatingSystemInterface), - processRef.overrideWith(() => shorebirdProcess), shorebirdEnvRef.overrideWith(() => shorebirdEnv), shorebirdValidatorRef.overrideWith(() => shorebirdValidator), }, @@ -54,37 +52,25 @@ void main() { }); setUp(() { + artifactBuilder = MockArtifactBuilder(); argResults = MockArgResults(); doctor = MockDoctor(); ios = MockIos(); logger = MockShorebirdLogger(); operatingSystemInterface = MockOperatingSystemInterface(); - shorebirdProcess = MockShorebirdProcess(); - buildProcessResult = MockProcessResult(); - flutterPubGetProcessResult = MockProcessResult(); flutterValidator = MockShorebirdFlutterValidator(); shorebirdEnv = MockShorebirdEnv(); shorebirdValidator = MockShorebirdValidator(); - when( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).thenAnswer((_) async => flutterPubGetProcessResult); - when(() => flutterPubGetProcessResult.exitCode) - .thenReturn(ExitCode.success.code); - when( - () => shorebirdProcess.run( - any(), - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => buildProcessResult); when(() => argResults['codesign']).thenReturn(true); when(() => argResults.rest).thenReturn([]); + when( + () => artifactBuilder.buildIpa( + flavor: any(named: 'flavor'), + target: any(named: 'target'), + codesign: any(named: 'codesign'), + ), + ).thenAnswer((_) async => File('')); when(() => ios.createExportOptionsPlist()).thenReturn(File('.')); when(() => logger.progress(any())).thenReturn(MockProgress()); when(() => logger.info(any())).thenReturn(null); @@ -131,42 +117,26 @@ void main() { }); test('exits with code 70 when building ipa fails', () async { - when(() => buildProcessResult.exitCode).thenReturn(1); - when(() => buildProcessResult.stderr).thenReturn('oops'); + when( + () => artifactBuilder.buildIpa( + flavor: any(named: 'flavor'), + target: any(named: 'target'), + codesign: any(named: 'codesign'), + ), + ).thenThrow(ArtifactBuildException('oops')); final exitCode = await runWithOverrides(command.run); expect(exitCode, equals(ExitCode.software.code)); - verify( - () => shorebirdProcess.run( - 'flutter', - any( - that: containsAll( - ['build', 'ipa', '--release'], - ), - ), - runInShell: any(named: 'runInShell'), - ), - ).called(1); + verify(() => artifactBuilder.buildIpa()).called(1); }); test('exits with code 0 when building ipa succeeds', () async { - when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code); final exitCode = await runWithOverrides(command.run); expect(exitCode, equals(ExitCode.success.code)); - verify( - () => shorebirdProcess.run( - 'flutter', - any( - that: containsAll( - ['build', 'ipa', '--release'], - ), - ), - runInShell: true, - ), - ).called(1); + verify(() => artifactBuilder.buildIpa()).called(1); verifyInOrder([ () => logger.info( @@ -182,22 +152,6 @@ ${lightCyan.wrap(p.join('build', 'ios', 'ipa', 'Runner.ipa'))}''', ]); }); - test('runs flutter pub get with system flutter after successful build', - () async { - when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code); - - await runWithOverrides(command.run); - - verify( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).called(1); - }); - test( 'exits with code 0 when building ipa succeeds ' 'with flavor and target', () async { @@ -205,27 +159,12 @@ ${lightCyan.wrap(p.join('build', 'ios', 'ipa', 'Runner.ipa'))}''', final target = p.join('lib', 'main_development.dart'); when(() => argResults['flavor']).thenReturn(flavor); when(() => argResults['target']).thenReturn(target); - when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code); final exitCode = await runWithOverrides(command.run); expect(exitCode, equals(ExitCode.success.code)); verify( - () => shorebirdProcess.run( - 'flutter', - any( - that: containsAll( - [ - 'build', - 'ipa', - '--release', - '--flavor=$flavor', - '--target=$target', - ], - ), - ), - runInShell: true, - ), + () => artifactBuilder.buildIpa(flavor: flavor, target: target), ).called(1); verifyInOrder([ @@ -246,19 +185,12 @@ ${lightCyan.wrap(p.join('build', 'ios', 'ipa', 'Runner.ipa'))}''', 'exits with code 0 when building ipa succeeds ' 'with --no-codesign', () async { when(() => argResults['codesign']).thenReturn(false); - when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code); final exitCode = await runWithOverrides(command.run); expect(exitCode, equals(ExitCode.success.code)); verify( - () => shorebirdProcess.run( - 'flutter', - any( - that: containsAll(['build', 'ipa', '--release', '--no-codesign']), - ), - runInShell: true, - ), + () => artifactBuilder.buildIpa(codesign: false), ).called(1); verify( diff --git a/packages/shorebird_cli/test/src/commands/patch_new/aar_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/aar_patcher_test.dart similarity index 99% rename from packages/shorebird_cli/test/src/commands/patch_new/aar_patcher_test.dart rename to packages/shorebird_cli/test/src/commands/patch/aar_patcher_test.dart index 02d0bc36..e22ea7a3 100644 --- a/packages/shorebird_cli/test/src/commands/patch_new/aar_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/aar_patcher_test.dart @@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; import 'package:shorebird_cli/src/engine_config.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; diff --git a/packages/shorebird_cli/test/src/commands/patch_new/android_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart similarity index 99% rename from packages/shorebird_cli/test/src/commands/patch_new/android_patcher_test.dart rename to packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart index 7770b01b..1721ff10 100644 --- a/packages/shorebird_cli/test/src/commands/patch_new/android_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart @@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/engine_config.dart'; import 'package:shorebird_cli/src/logger.dart'; diff --git a/packages/shorebird_cli/test/src/commands/patch_new/ios_framework_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart similarity index 99% rename from packages/shorebird_cli/test/src/commands/patch_new/ios_framework_patcher_test.dart rename to packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart index aaa8ae35..d1b9128c 100644 --- a/packages/shorebird_cli/test/src/commands/patch_new/ios_framework_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart @@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/engine_config.dart'; import 'package:shorebird_cli/src/executables/aot_tools.dart'; diff --git a/packages/shorebird_cli/test/src/commands/patch_new/ios_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart similarity index 99% rename from packages/shorebird_cli/test/src/commands/patch_new/ios_patcher_test.dart rename to packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart index 723ca7b7..4e2c8faf 100644 --- a/packages/shorebird_cli/test/src/commands/patch_new/ios_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart @@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/engine_config.dart'; import 'package:shorebird_cli/src/executables/aot_tools.dart'; diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_aar_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_aar_command_test.dart deleted file mode 100644 index 48df41d8..00000000 --- a/packages/shorebird_cli/test/src/commands/patch/patch_aar_command_test.dart +++ /dev/null @@ -1,930 +0,0 @@ -import 'dart:io' hide Platform; - -import 'package:args/args.dart'; -import 'package:http/http.dart' as http; -import 'package:mason_logger/mason_logger.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; -import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; -import 'package:shorebird_cli/src/artifact_manager.dart'; -import 'package:shorebird_cli/src/auth/auth.dart'; -import 'package:shorebird_cli/src/cache.dart' show Cache, cacheRef; -import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/commands.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/deployment_track.dart'; -import 'package:shorebird_cli/src/engine_config.dart'; -import 'package:shorebird_cli/src/http_client/http_client.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/os/operating_system_interface.dart'; -import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_flutter.dart'; -import 'package:shorebird_cli/src/shorebird_process.dart'; -import 'package:shorebird_cli/src/shorebird_validator.dart'; -import 'package:shorebird_cli/src/version.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; -import 'package:test/test.dart'; - -import '../../fakes.dart'; -import '../../mocks.dart'; - -void main() { - group(PatchAarCommand, () { - const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; - const flutterVersionAndRevision = '3.10.6 (83305b5088)'; - const appId = 'test-app-id'; - const shorebirdYaml = ShorebirdYaml(appId: appId); - const buildNumber = '1.0'; - const versionName = '1.2.3'; - const versionCode = '1'; - const version = '$versionName+$versionCode'; - const operatingSystem = 'macOS'; - const operatingSystemVersion = '11.0.0'; - const arch = 'aarch64'; - const releasePlatform = ReleasePlatform.android; - const track = DeploymentTrack.production; - const appDisplayName = 'Test App'; - final appMetadata = AppMetadata( - appId: appId, - displayName: appDisplayName, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - const androidPackageName = 'com.example.my_flutter_module'; - const releaseArtifact = ReleaseArtifact( - id: 0, - releaseId: 0, - arch: arch, - platform: releasePlatform, - hash: '#', - size: 42, - url: 'https://example.com/release.so', - ); - const aarArtifact = ReleaseArtifact( - id: 0, - releaseId: 0, - arch: arch, - platform: releasePlatform, - hash: '#', - size: 42, - url: 'https://example.com/release.aar', - ); - final release = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - final releaseArtifactFile = File('release.artifact'); - - late AndroidArchiveDiffer archiveDiffer; - late ArgResults argResults; - late ArtifactManager artifactManager; - late Auth auth; - late CodePushClientWrapper codePushClientWrapper; - late Directory shorebirdRoot; - late Directory projectRoot; - late Directory flutterDirectory; - late OperatingSystemInterface operatingSystemInterface; - late PatchDiffChecker patchDiffChecker; - late Platform platform; - late Progress progress; - late ShorebirdLogger logger; - late ShorebirdProcessResult flutterBuildProcessResult; - late ShorebirdProcessResult flutterPubGetProcessResult; - late http.Client httpClient; - late Cache cache; - late ShorebirdEnv shorebirdEnv; - late ShorebirdFlutter shorebirdFlutter; - late ShorebirdProcess shorebirdProcess; - late ShorebirdValidator shorebirdValidator; - late PatchAarCommand command; - - R runWithOverrides(R Function() body) { - return runScoped( - body, - values: { - artifactManagerRef.overrideWith(() => artifactManager), - authRef.overrideWith(() => auth), - cacheRef.overrideWith(() => cache), - codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), - engineConfigRef.overrideWith(() => const EngineConfig.empty()), - httpClientRef.overrideWith(() => httpClient), - loggerRef.overrideWith(() => logger), - osInterfaceRef.overrideWith(() => operatingSystemInterface), - patchDiffCheckerRef.overrideWith(() => patchDiffChecker), - platformRef.overrideWith(() => platform), - processRef.overrideWith(() => shorebirdProcess), - shorebirdEnvRef.overrideWith(() => shorebirdEnv), - shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), - shorebirdValidatorRef.overrideWith(() => shorebirdValidator), - }, - ); - } - - void setUpProjectRootArtifacts() { - final aarDir = p.join( - projectRoot.path, - 'build', - 'host', - 'outputs', - 'repo', - 'com', - 'example', - 'my_flutter_module', - 'flutter_release', - buildNumber, - ); - final aarPath = p.join(aarDir, 'flutter_release-$buildNumber.aar'); - for (final archMetadata in Arch.values) { - final artifactPath = p.join( - aarDir, - 'flutter_release-$buildNumber', - 'jni', - archMetadata.androidBuildPath, - 'libapp.so', - ); - File(artifactPath).createSync(recursive: true); - } - File(aarPath).createSync(recursive: true); - } - - setUpAll(() { - registerFallbackValue(CreatePatchMetadata.forTest()); - registerFallbackValue(Directory('')); - registerFallbackValue(File('')); - registerFallbackValue(FileSetDiff.empty()); - registerFallbackValue(MockHttpClient()); - registerFallbackValue(Uri.parse('https://example.com')); - registerFallbackValue(FakeBaseRequest()); - registerFallbackValue(FakeShorebirdProcess()); - registerFallbackValue(ReleasePlatform.android); - registerFallbackValue(DeploymentTrack.production); - }); - - setUp(() { - archiveDiffer = MockAndroidArchiveDiffer(); - argResults = MockArgResults(); - artifactManager = MockArtifactManager(); - auth = MockAuth(); - codePushClientWrapper = MockCodePushClientWrapper(); - shorebirdRoot = Directory.systemTemp.createTempSync(); - projectRoot = Directory.systemTemp.createTempSync(); - flutterDirectory = Directory( - p.join(shorebirdRoot.path, 'bin', 'cache', 'flutter'), - ); - operatingSystemInterface = MockOperatingSystemInterface(); - patchDiffChecker = MockPatchDiffChecker(); - platform = MockPlatform(); - progress = MockProgress(); - logger = MockShorebirdLogger(); - flutterBuildProcessResult = MockProcessResult(); - flutterPubGetProcessResult = MockProcessResult(); - httpClient = MockHttpClient(); - cache = MockCache(); - shorebirdEnv = MockShorebirdEnv(); - shorebirdFlutter = MockShorebirdFlutter(); - shorebirdProcess = MockShorebirdProcess(); - shorebirdValidator = MockShorebirdValidator(); - - when(() => operatingSystemInterface.which('flutter')) - .thenReturn('/path/to/flutter'); - when(() => platform.environment).thenReturn({}); - when(() => platform.operatingSystem).thenReturn(operatingSystem); - when(() => platform.operatingSystemVersion) - .thenReturn(operatingSystemVersion); - when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); - when( - () => shorebirdEnv.copyWith( - flutterRevisionOverride: any(named: 'flutterRevisionOverride'), - ), - ).thenAnswer((invocation) { - when(() => shorebirdEnv.flutterRevision).thenReturn( - invocation.namedArguments[#flutterRevisionOverride] as String, - ); - return shorebirdEnv; - }); - when( - () => shorebirdEnv.getShorebirdProjectRoot(), - ).thenReturn(projectRoot); - when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory); - when( - () => shorebirdEnv.androidPackageName, - ).thenReturn(androidPackageName); - when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); - when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision); - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); - when( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).thenAnswer((_) async => flutterPubGetProcessResult); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - when( - () => artifactManager.createDiff( - patchArtifactPath: any(named: 'patchArtifactPath'), - releaseArtifactPath: any(named: 'releaseArtifactPath'), - ), - ).thenAnswer((_) async { - final tempDir = await Directory.systemTemp.createTemp(); - final diffPath = p.join(tempDir.path, 'diff.patch'); - File(diffPath) - ..createSync(recursive: true) - ..writeAsStringSync('diff'); - return diffPath; - }); - when(() => artifactManager.downloadFile(any())) - .thenAnswer((_) async => releaseArtifactFile); - when( - () => archiveDiffer.changedFiles(any(), any()), - ).thenAnswer((_) async => FileSetDiff.empty()); - when( - () => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any()), - ).thenReturn(false); - when(() => argResults.rest).thenReturn([]); - when(() => argResults['dry-run']).thenReturn(false); - when(() => argResults['build-number']).thenReturn(buildNumber); - when(() => argResults['release-version']).thenReturn(version); - when(() => auth.isAuthenticated).thenReturn(true); - when(() => auth.client).thenReturn(httpClient); - when(() => logger.level).thenReturn(Level.info); - when(() => logger.progress(any())).thenReturn(progress); - when( - () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), - ).thenReturn(version); - when(() => logger.confirm(any())).thenReturn(true); - when( - () => flutterBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when( - () => flutterPubGetProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when(() => httpClient.send(any())).thenAnswer( - (_) async => http.StreamedResponse(const Stream.empty(), HttpStatus.ok), - ); - when( - () => codePushClientWrapper.getApp(appId: any(named: 'appId')), - ).thenAnswer((_) async => appMetadata); - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer((_) async => [release]); - when( - () => codePushClientWrapper.getReleaseArtifacts( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - architectures: any(named: 'architectures'), - platform: any(named: 'platform'), - ), - ).thenAnswer( - (_) async => { - Arch.arm32: releaseArtifact, - Arch.arm64: releaseArtifact, - Arch.x86_64: releaseArtifact, - }, - ); - when( - () => codePushClientWrapper.getReleaseArtifact( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - arch: 'aar', - platform: ReleasePlatform.android, - ), - ).thenAnswer((_) async => aarArtifact); - when( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).thenAnswer((_) async {}); - when(() => cache.updateAll()).thenAnswer((_) async => {}); - when( - () => cache.getArtifactDirectory(any()), - ).thenReturn(Directory.systemTemp.createTempSync()); - when( - () => shorebirdFlutter.getVersionAndRevision(), - ).thenAnswer((_) async => flutterVersionAndRevision); - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async {}); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - ), - ).thenAnswer((_) async {}); - when( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: false, - hasNativeChanges: false, - ), - ); - - command = runWithOverrides( - () => PatchAarCommand( - archiveDiffer: archiveDiffer, - unzipFn: (_, __) async {}, - ), - )..testArgResults = argResults; - }); - - test('has a description', () { - expect(command.description, isNotEmpty); - }); - - test('exits when validation fails', () async { - final exception = ValidationFailedException(); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - ), - ).thenThrow(exception); - await expectLater( - runWithOverrides(command.run), - completion(equals(exception.exitCode.code)), - ); - verify( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - ), - ).called(1); - }); - - test('exits with 78 if no module entry exists in pubspec.yaml', () async { - when(() => shorebirdEnv.androidPackageName).thenReturn(null); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.config.code); - }); - - test('prompts for release when release-version is not specified', () async { - when(() => argResults['release-version']).thenReturn(null); - when( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: any(named: 'display'), - ), - ).thenReturn(release); - try { - await runWithOverrides(command.run); - } catch (_) {} - await untilCalled( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: any(named: 'display'), - ), - ); - final display = verify( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: captureAny(named: 'display'), - ), - ).captured.single as String Function(Release); - expect(display(release), equals(release.version)); - }); - - test('exits early when no releases are found', () async { - when(() => argResults['release-version']).thenReturn(null); - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer((_) async => []); - try { - await runWithOverrides(command.run); - } catch (_) {} - verifyNever( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: captureAny(named: 'display'), - ), - ); - verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); - verify(() => logger.info('No releases found')).called(1); - }); - - test('exits early when specified release does not exist.', () async { - when(() => argResults['release-version']).thenReturn('0.0.0'); - try { - await runWithOverrides(command.run); - } catch (_) {} - verifyNever( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: captureAny(named: 'display'), - ), - ); - verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); - verify( - () => logger.info(''' -No release found for version 0.0.0 - -Available release versions: -${release.version}'''), - ).called(1); - }); - - test( - '''exits with code 70 if release is in draft state for the android platform''', - () async { - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer( - (_) async => [ - Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.android: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ], - ); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify( - () => logger.err(''' -Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''), - ).called(1); - }); - - test('proceeds if release is in draft state for non-android platform', - () async { - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer( - (_) async => [ - Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ], - ); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - }); - - group('when flutter version install fails', () { - setUp(() { - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenThrow(Exception('oops')); - }); - - test('exits with code 70', () async { - setUpProjectRootArtifacts(); - - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); - verify( - () => shorebirdFlutter.installRevision( - revision: release.flutterRevision, - ), - ).called(1); - }); - }); - - test('exits with code 70 when downloading release artifact fails', - () async { - final exception = Exception('oops'); - when( - () => artifactManager.downloadFile( - any(), - outputPath: any(named: 'outputPath'), - ), - ).thenThrow(exception); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify(() => progress.fail('$exception')).called(1); - expect(exitCode, ExitCode.software.code); - }); - - test( - 'builds using correct flutter revision ' - 'when release flutter revision differs', () async { - when( - () => platform.script, - ).thenReturn( - Uri.file(p.join('bin', 'cache', 'shorebird.snapshot')), - ); - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - final processWrapper = MockProcessWrapper(); - when( - () => processWrapper.run( - any(), - any(), - runInShell: any(named: 'runInShell'), - workingDirectory: any(named: 'workingDirectory'), - environment: any(named: 'environment'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - final flutterFile = File( - p.join( - '.', - 'bin', - 'cache', - 'flutter', - release.flutterRevision, - 'bin', - 'flutter', - ), - ); - when(() => shorebirdEnv.flutterBinaryFile).thenReturn(flutterFile); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - // Ensure we're building with the correct flutter revision. - expect(shorebirdEnv.flutterRevision, equals(release.flutterRevision)); - return flutterBuildProcessResult; - }); - setUpProjectRootArtifacts(); - - await runWithOverrides( - () => runScoped( - () => command.run(), - values: { - shorebirdEnvRef.overrideWith(() => shorebirdEnv), - processRef.overrideWith( - () => ShorebirdProcess(processWrapper: processWrapper), - ), - }, - ), - ); - - verify( - () => shorebirdFlutter.installRevision( - revision: release.flutterRevision, - ), - ).called(1); - verify( - () => processWrapper.run( - flutterFile.path, - any(), - runInShell: true, - workingDirectory: any(named: 'workingDirectory'), - environment: any(named: 'environment'), - ), - ).called(1); - }); - - test('exits with code 70 when building fails', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - }); - - test( - '''exits with code 0 if confirmUnpatchableDiffsIfNecessary throws UserCancelledException''', - () async { - when( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UserCancelledException()); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test( - '''exits with code 70 if confirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''', - () async { - when( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UnpatchableChangeException()); - - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test('exits with code 70 and prints error when creating diff fails', - () async { - final error = Exception('oops something went wrong'); - when( - () => artifactManager.createDiff( - releaseArtifactPath: any(named: 'releaseArtifactPath'), - patchArtifactPath: any(named: 'patchArtifactPath'), - ), - ).thenThrow(error); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify(() => progress.fail('$error')).called(1); - expect(exitCode, ExitCode.software.code); - }); - - test('does not create patch on --dry-run', () async { - when(() => argResults['dry-run']).thenReturn(true); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.success.code)); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - verify(() => logger.info('No issues detected.')).called(1); - }); - - test('aborts when user opts out', () async { - when(() => logger.confirm(any())).thenReturn(false); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify(() => logger.info('Aborting.')).called(1); - }); - - test( - '''forwards allow-asset-diffs and allow-native-diffs to patch diff checker''', - () async { - setUpProjectRootArtifacts(); - - when(() => argResults['allow-asset-diffs']).thenReturn(true); - when(() => argResults['allow-native-diffs']).thenReturn(true); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: true, - allowNativeChanges: true, - ), - ).called(1); - - when(() => argResults['allow-asset-diffs']).thenReturn(false); - when(() => argResults['allow-native-diffs']).thenReturn(false); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - }); - - test('reports when patch has asset and native changes', () async { - when(() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.changedFiles(any(), any())) - .thenAnswer((_) async => FileSetDiff.empty()); - when( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: true, - hasNativeChanges: true, - ), - ); - - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any( - named: 'metadata', - that: isA() - .having( - (m) => m.releasePlatform, - 'releasePlatform', - ReleasePlatform.android, - ) - .having( - (m) => m.hasAssetChanges, - 'hasAssetChanges', - true, - ) - .having( - (m) => m.hasNativeChanges, - 'hasNativeChanges', - true, - ), - ), - ), - ).called(1); - }); - - test('succeeds when patch is successful', () async { - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, ExitCode.success.code); - verify( - () => logger.info( - any( - that: contains( - ''' -đŸ•šī¸ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[arm32 (4 B), arm64 (4 B), x86_64 (4 B)]')} -đŸŸĸ Track: ${lightCyan.wrap('Production')}''', - ), - ), - ), - ).called(1); - - verify(() => codePushClientWrapper.getApp(appId: appId)).called(1); - verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); - verify( - () => codePushClientWrapper.getReleaseArtifacts( - appId: appId, - releaseId: release.id, - architectures: Arch.values, - platform: releasePlatform, - ), - ).called(1); - verify( - () => codePushClientWrapper.getReleaseArtifact( - appId: appId, - releaseId: release.id, - arch: 'aar', - platform: releasePlatform, - ), - ).called(1); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: const CreatePatchMetadata( - releasePlatform: releasePlatform, - usedIgnoreAssetChangesFlag: false, - hasAssetChanges: false, - usedIgnoreNativeChangesFlag: false, - hasNativeChanges: false, - linkPercentage: null, - environment: BuildEnvironmentMetadata( - shorebirdVersion: packageVersion, - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - xcodeVersion: null, - ), - ), - ), - ).called(1); - }); - - test('runs flutter pub get with system flutter after successful build', - () async { - setUpProjectRootArtifacts(); - - await runWithOverrides(command.run); - - verify( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).called(1); - }); - - test('does not prompt if unable to accept user input', () async { - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verifyNever(() => logger.confirm(any())); - }); - }); -} diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart deleted file mode 100644 index 23197452..00000000 --- a/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart +++ /dev/null @@ -1,1089 +0,0 @@ -import 'dart:io' hide Platform; - -import 'package:args/args.dart'; -import 'package:http/http.dart' as http; -import 'package:mason_logger/mason_logger.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; -import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; -import 'package:shorebird_cli/src/artifact_manager.dart'; -import 'package:shorebird_cli/src/auth/auth.dart'; -import 'package:shorebird_cli/src/cache.dart' show Cache, cacheRef; -import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/commands.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/deployment_track.dart'; -import 'package:shorebird_cli/src/doctor.dart'; -import 'package:shorebird_cli/src/engine_config.dart'; -import 'package:shorebird_cli/src/executables/executables.dart'; -import 'package:shorebird_cli/src/http_client/http_client.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/os/operating_system_interface.dart'; -import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/shorebird_android_artifacts.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_flutter.dart'; -import 'package:shorebird_cli/src/shorebird_process.dart'; -import 'package:shorebird_cli/src/shorebird_validator.dart'; -import 'package:shorebird_cli/src/validators/validators.dart'; -import 'package:shorebird_cli/src/version.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; -import 'package:test/test.dart'; - -import '../../fakes.dart'; -import '../../mocks.dart'; - -void main() { - group(PatchAndroidCommand, () { - const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; - const flutterVersionAndRevision = '3.10.6 (83305b5088)'; - const appId = 'test-app-id'; - const shorebirdYaml = ShorebirdYaml(appId: appId); - const versionName = '1.2.3'; - const versionCode = '1'; - const version = '$versionName+$versionCode'; - const operatingSystem = 'macOS'; - const operatingSystemVersion = '11.0.0'; - const arch = 'aarch64'; - const releasePlatform = ReleasePlatform.android; - const track = DeploymentTrack.production; - const appDisplayName = 'Test App'; - final appMetadata = AppMetadata( - appId: appId, - displayName: appDisplayName, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - const releaseArtifact = ReleaseArtifact( - id: 0, - releaseId: 0, - arch: arch, - platform: releasePlatform, - hash: '#', - size: 42, - url: 'https://example.com', - ); - const aabArtifact = ReleaseArtifact( - id: 0, - releaseId: 0, - arch: arch, - platform: releasePlatform, - hash: '#', - size: 42, - url: 'https://example.com/release.aab', - ); - final release = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - final releaseArtifactFile = File('release.artifact'); - const pubspecYamlContent = ''' -name: example -version: $version -environment: - sdk: ">=2.19.0 <3.0.0" - -flutter: - assets: - - shorebird.yaml'''; - - late AndroidArchiveDiffer archiveDiffer; - late ArgResults argResults; - late ArtifactManager artifactManager; - late Auth auth; - late Bundletool bundletool; - late CodePushClientWrapper codePushClientWrapper; - late Directory flutterDirectory; - late Directory shorebirdRoot; - late Directory projectRoot; - late Doctor doctor; - late Java java; - late OperatingSystemInterface operatingSystemInterface; - late PatchDiffChecker patchDiffChecker; - late Platform platform; - late Progress progress; - late ShorebirdLogger logger; - late ShorebirdEnv shorebirdEnv; - late ShorebirdProcessResult flutterBuildProcessResult; - late ShorebirdProcessResult flutterPubGetProcessResult; - late http.Client httpClient; - late Cache cache; - late ShorebirdFlutter shorebirdFlutter; - late ShorebirdFlutterValidator flutterValidator; - late ShorebirdProcess shorebirdProcess; - late ShorebirdValidator shorebirdValidator; - late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts; - late PatchAndroidCommand command; - - R runWithOverrides(R Function() body) { - return runScoped( - body, - values: { - artifactManagerRef.overrideWith(() => artifactManager), - authRef.overrideWith(() => auth), - bundletoolRef.overrideWith(() => bundletool), - cacheRef.overrideWith(() => cache), - codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), - doctorRef.overrideWith(() => doctor), - engineConfigRef.overrideWith(() => const EngineConfig.empty()), - httpClientRef.overrideWith(() => httpClient), - javaRef.overrideWith(() => java), - loggerRef.overrideWith(() => logger), - osInterfaceRef.overrideWith(() => operatingSystemInterface), - patchDiffCheckerRef.overrideWith(() => patchDiffChecker), - shorebirdEnvRef.overrideWith(() => shorebirdEnv), - platformRef.overrideWith(() => platform), - processRef.overrideWith(() => shorebirdProcess), - shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), - shorebirdValidatorRef.overrideWith(() => shorebirdValidator), - shorebirdAndroidArtifactsRef.overrideWith( - () => shorebirdAndroidArtifacts, - ), - }, - ); - } - - void setUpProjectRoot() { - File( - p.join(projectRoot.path, 'pubspec.yaml'), - ).writeAsStringSync(pubspecYamlContent); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync('app_id: $appId'); - } - - void setUpProjectRootArtifacts({String? flavor}) { - for (final archMetadata in Arch.values) { - final artifactPath = p.join( - projectRoot.path, - 'build', - 'app', - 'intermediates', - 'stripped_native_libs', - flavor != null ? '${flavor}Release' : 'release', - 'out', - 'lib', - archMetadata.androidBuildPath, - 'libapp.so', - ); - File(artifactPath).createSync(recursive: true); - } - } - - setUpAll(() { - registerFallbackValue(CreatePatchMetadata.forTest()); - registerFallbackValue(Directory('')); - registerFallbackValue(File('')); - registerFallbackValue(FileSetDiff.empty()); - registerFallbackValue(MockHttpClient()); - registerFallbackValue(Uri.parse('https://example.com')); - registerFallbackValue(ReleasePlatform.android); - registerFallbackValue(FakeBaseRequest()); - registerFallbackValue(FakeShorebirdProcess()); - registerFallbackValue(DeploymentTrack.production); - }); - - setUp(() { - archiveDiffer = MockAndroidArchiveDiffer(); - argResults = MockArgResults(); - artifactManager = MockArtifactManager(); - auth = MockAuth(); - bundletool = MockBundleTool(); - codePushClientWrapper = MockCodePushClientWrapper(); - doctor = MockDoctor(); - java = MockJava(); - shorebirdRoot = Directory.systemTemp.createTempSync(); - projectRoot = Directory.systemTemp.createTempSync(); - flutterDirectory = Directory( - p.join(shorebirdRoot.path, 'bin', 'cache', 'flutter'), - ); - patchDiffChecker = MockPatchDiffChecker(); - platform = MockPlatform(); - progress = MockProgress(); - logger = MockShorebirdLogger(); - flutterBuildProcessResult = MockProcessResult(); - flutterPubGetProcessResult = MockProcessResult(); - httpClient = MockHttpClient(); - flutterValidator = MockShorebirdFlutterValidator(); - cache = MockCache(); - operatingSystemInterface = MockOperatingSystemInterface(); - shorebirdEnv = MockShorebirdEnv(); - shorebirdProcess = MockShorebirdProcess(); - shorebirdFlutter = MockShorebirdFlutter(); - shorebirdValidator = MockShorebirdValidator(); - shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts(); - command = runWithOverrides( - () => PatchAndroidCommand(archiveDiffer: archiveDiffer), - )..testArgResults = argResults; - - when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); - when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); - when( - () => shorebirdEnv.getShorebirdProjectRoot(), - ).thenReturn(projectRoot); - when( - () => shorebirdEnv.copyWith( - flutterRevisionOverride: any(named: 'flutterRevisionOverride'), - ), - ).thenAnswer((invocation) { - when(() => shorebirdEnv.flutterRevision).thenReturn( - invocation.namedArguments[#flutterRevisionOverride] as String, - ); - return shorebirdEnv; - }); - when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory); - when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision); - when( - () => shorebirdFlutter.getVersionAndRevision(), - ).thenAnswer((_) async => flutterVersionAndRevision); - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async {}); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - when( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).thenAnswer((_) async => flutterPubGetProcessResult); - when( - () => artifactManager.createDiff( - patchArtifactPath: any(named: 'patchArtifactPath'), - releaseArtifactPath: any(named: 'releaseArtifactPath'), - ), - ).thenAnswer((_) async { - final tempDir = await Directory.systemTemp.createTemp(); - final diffPath = p.join(tempDir.path, 'diff.patch'); - File(diffPath) - ..createSync(recursive: true) - ..writeAsStringSync('diff'); - return diffPath; - }); - when(() => artifactManager.downloadFile(any())) - .thenAnswer((_) async => releaseArtifactFile); - when( - () => archiveDiffer.changedFiles(any(), any()), - ).thenAnswer((_) async => FileSetDiff.empty()); - when( - () => archiveDiffer.assetsFileSetDiff(any()), - ).thenReturn(FileSetDiff.empty()); - when( - () => archiveDiffer.nativeFileSetDiff(any()), - ).thenReturn(FileSetDiff.empty()); - when( - () => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any()), - ).thenReturn(false); - when( - () => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any()), - ).thenReturn(false); - when(() => argResults['arch']).thenReturn(arch); - when(() => argResults['staging']).thenReturn(false); - when(() => argResults['dry-run']).thenReturn(false); - when(() => argResults.rest).thenReturn([]); - when(() => argResults.wasParsed(any())).thenReturn(true); - when(() => auth.isAuthenticated).thenReturn(true); - when(() => auth.client).thenReturn(httpClient); - when(() => logger.progress(any())).thenReturn(progress); - when( - () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), - ).thenReturn(version); - when(() => logger.confirm(any())).thenReturn(true); - when( - () => flutterBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when(() => flutterPubGetProcessResult.exitCode).thenReturn( - ExitCode.success.code, - ); - when(() => httpClient.send(any())).thenAnswer( - (_) async => http.StreamedResponse(const Stream.empty(), HttpStatus.ok), - ); - - when( - () => codePushClientWrapper.getApp( - appId: any(named: 'appId'), - ), - ).thenAnswer((_) async => appMetadata); - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => release); - when( - () => codePushClientWrapper.getReleaseArtifacts( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - architectures: any(named: 'architectures'), - platform: any(named: 'platform'), - ), - ).thenAnswer( - (_) async => { - Arch.arm32: releaseArtifact, - Arch.arm64: releaseArtifact, - Arch.x86_64: releaseArtifact, - }, - ); - when( - () => codePushClientWrapper.getReleaseArtifact( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - arch: 'aab', - platform: ReleasePlatform.android, - ), - ).thenAnswer((_) async => aabArtifact); - when( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).thenAnswer((_) async {}); - when( - () => doctor.androidCommandValidators, - ).thenReturn([flutterValidator]); - when(flutterValidator.validate).thenAnswer((_) async => []); - when(() => cache.updateAll()).thenAnswer((_) async => {}); - when(() => bundletool.getVersionName(any())).thenAnswer( - (_) async => versionName, - ); - when(() => bundletool.getVersionCode(any())).thenAnswer( - (_) async => versionCode, - ); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - ), - ).thenAnswer((_) async {}); - when(() => operatingSystemInterface.which('flutter')) - .thenReturn('/path/to/flutter'); - when( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: false, - hasNativeChanges: false, - ), - ); - when(() => platform.operatingSystem).thenReturn(operatingSystem); - when(() => platform.operatingSystemVersion) - .thenReturn(operatingSystemVersion); - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); - - when( - () => shorebirdAndroidArtifacts.findAab( - project: any(named: 'project'), - flavor: any(named: 'flavor'), - ), - ).thenReturn(File('release.aab')); - }); - - test('has a description', () { - expect(command.description, isNotEmpty); - }); - - test('exits when validation fails', () async { - final exception = ValidationFailedException(); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - ), - ).thenThrow(exception); - await expectLater( - runWithOverrides(command.run), - completion(equals(exception.exitCode.code)), - ); - verify( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - validators: [flutterValidator], - ), - ).called(1); - }); - - test('exits with code 70 when building fails', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); - - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - }); - - test('exits with code 70 when build artifacts cannot be found', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify(() => logger.err('Cannot find patch build artifacts.')).called(1); - verify( - () => logger.info( - any( - that: contains('Please run `shorebird cache clean` and try again'), - ), - ), - ).called(1); - }); - - test( - '''exits with code 70 if release is in draft state for the android platform''', - () async { - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer( - (_) async => Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {releasePlatform: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify( - () => logger.err(''' -Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''), - ).called(1); - }); - - test('proceeds if release is in draft state for non-android platform', - () async { - when( - () => codePushClientWrapper.getReleases( - appId: any(named: 'appId'), - ), - ).thenAnswer( - (_) async => [ - Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ], - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - }); - - test( - '''switches to release flutter revision when shorebird flutter revision does not match''', - () async { - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, ExitCode.success.code); - verify( - () => logger.info( - 'Release was built with Flutter revision ${release.flutterRevision}', - ), - ).called(1); - }); - - group('when release-version option is provided', () { - setUp(() { - when(() => argResults['release-version']).thenReturn(release.version); - }); - - test('does not extract release version from app bundle', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - await runWithOverrides(command.run); - verifyNever(() => bundletool.getVersionName(any())); - verifyNever(() => bundletool.getVersionCode(any())); - verifyNever(() => logger.progress('Detecting release version')); - }); - - test('exits with code 70 if build fails', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - }); - - test('only builds once if release uses different flutter revision', - () async { - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - // Ensure we're building with the correct flutter revision. - expect(shorebirdEnv.flutterRevision, equals(release.flutterRevision)); - return flutterBuildProcessResult; - }); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify( - () => shorebirdEnv.copyWith(flutterRevisionOverride: flutterRevision), - ).called(1); - - verify( - () => shorebirdProcess.run( - 'flutter', - [ - 'build', - 'appbundle', - '--release', - ], - runInShell: any(named: 'runInShell'), - ), - ).called(1); - }); - }); - - group('when release-version option is not provided', () { - test('extracts release version from app bundle', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - await runWithOverrides(command.run); - verify(() => bundletool.getVersionName(any())).called(1); - verify(() => bundletool.getVersionCode(any())).called(1); - verify(() => logger.progress('Detecting release version')).called(1); - }); - }); - - test('errors when detecting release version name fails', () async { - final exception = Exception( - 'Failed to extract version name from app bundle: oops', - ); - when(() => bundletool.getVersionName(any())).thenThrow(exception); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify(() => progress.fail('$exception')).called(1); - }); - - test('errors when detecting release version code fails', () async { - final exception = Exception( - 'Failed to extract version code from app bundle: oops', - ); - when(() => bundletool.getVersionCode(any())).thenThrow(exception); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify(() => progress.fail('$exception')).called(1); - }); - - test('prints release version when detected', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => progress.complete('Detected release version 1.2.3+1'), - ).called(1); - }); - - test('aborts when user opts out', () async { - when(() => logger.confirm(any())).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify(() => logger.info('Aborting.')).called(1); - }); - - test('exits with code 70 when downloading release artifact fails', - () async { - final exception = Exception('oops'); - when( - () => artifactManager.downloadFile( - any(), - outputPath: any(named: 'outputPath'), - ), - ).thenThrow(exception); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify(() => progress.fail('$exception')).called(1); - expect(exitCode, ExitCode.software.code); - }); - - test('errors when detecting release version name fails', () async { - final exception = Exception( - 'Failed to extract version name from app bundle: oops', - ); - when(() => bundletool.getVersionName(any())).thenThrow(exception); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify(() => progress.fail('$exception')).called(1); - }); - - test('errors when detecting release version code fails', () async { - final exception = Exception( - 'Failed to extract version code from app bundle: oops', - ); - when(() => bundletool.getVersionCode(any())).thenThrow(exception); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify(() => progress.fail('$exception')).called(1); - }); - - test('prints release version when detected', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => progress.complete('Detected release version 1.2.3+1'), - ).called(1); - }); - - group('when flutter version install fails', () { - setUp(() { - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenThrow(Exception('oops')); - }); - - test('exits with code 70', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); - verify( - () => shorebirdFlutter.installRevision( - revision: release.flutterRevision, - ), - ).called(1); - }); - }); - - test( - '''exits with code 0 if confirmUnpatchableDiffsIfNecessary throws UserCancelledException''', - () async { - when( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UserCancelledException()); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test( - '''exits with code 70 if confirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''', - () async { - when( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UnpatchableChangeException()); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test('exits with code 70 and prints error when creating diff fails', - () async { - final error = Exception('oops something went wrong'); - when( - () => artifactManager.createDiff( - releaseArtifactPath: any(named: 'releaseArtifactPath'), - patchArtifactPath: any(named: 'patchArtifactPath'), - ), - ).thenThrow(error); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify(() => progress.fail('$error')).called(1); - expect(exitCode, ExitCode.software.code); - }); - - test('does not create patch on --dry-run', () async { - when(() => argResults['dry-run']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.success.code)); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - verify(() => logger.info('No issues detected.')).called(1); - }); - - test( - '''forwards allow-asset-diffs and allow-native-diffs to patch diff checker''', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - when(() => argResults['allow-asset-diffs']).thenReturn(true); - when(() => argResults['allow-native-diffs']).thenReturn(true); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: true, - allowNativeChanges: true, - ), - ).called(1); - - when(() => argResults['allow-asset-diffs']).thenReturn(false); - when(() => argResults['allow-native-diffs']).thenReturn(false); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - }); - - test('reports when patch has asset and native changes', () async { - when(() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.changedFiles(any(), any())) - .thenAnswer((_) async => FileSetDiff.empty()); - when( - () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( - localArtifact: any(named: 'localArtifact'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: true, - hasNativeChanges: true, - ), - ); - - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any( - named: 'metadata', - that: isA() - .having( - (m) => m.releasePlatform, - 'releasePlatform', - ReleasePlatform.android, - ) - .having( - (m) => m.hasAssetChanges, - 'hasAssetChanges', - true, - ) - .having( - (m) => m.hasNativeChanges, - 'hasNativeChanges', - true, - ), - ), - ), - ).called(1); - }); - - test('succeeds when patch is successful (production)', () async { - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - // Ensure we're building with the correct flutter revision. - expect(shorebirdEnv.flutterRevision, equals(release.flutterRevision)); - return flutterBuildProcessResult; - }); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - verify( - () => shorebirdFlutter.installRevision( - revision: release.flutterRevision, - ), - ).called(1); - verify( - () => logger.info( - any( - that: contains( - ''' -đŸ•šī¸ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[arm32 (4 B), arm64 (4 B), x86_64 (4 B)]')} -đŸŸĸ Track: ${lightCyan.wrap('Production')}''', - ), - ), - ), - ).called(1); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: const CreatePatchMetadata( - releasePlatform: releasePlatform, - usedIgnoreAssetChangesFlag: false, - hasAssetChanges: false, - usedIgnoreNativeChangesFlag: false, - hasNativeChanges: false, - linkPercentage: null, - environment: BuildEnvironmentMetadata( - shorebirdVersion: packageVersion, - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - xcodeVersion: null, - ), - ), - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); - - test('succeeds when patch is successful (staging)', () async { - when(() => argResults['staging']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => logger.info( - any( - that: contains( - ''' -đŸ•šī¸ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[arm32 (4 B), arm64 (4 B), x86_64 (4 B)]')} -🟠 Track: ${lightCyan.wrap('Staging')}''', - ), - ), - ), - ).called(1); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - track: DeploymentTrack.staging, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); - - test('runs flutter pub get with system flutter after successful build', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - await runWithOverrides(command.run); - - verify( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).called(1); - }); - - test('succeeds when patch is successful with flavors and target', () async { - const flavor = 'development'; - const target = './lib/main_development.dart'; - when(() => argResults['flavor']).thenReturn(flavor); - when(() => argResults['target']).thenReturn(target); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - // Ensure we're building with the correct flutter revision. - expect(shorebirdEnv.flutterRevision, equals(release.flutterRevision)); - return flutterBuildProcessResult; - }); - setUpProjectRoot(); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync(''' -app_id: productionAppId -flavors: - development: $appId'''); - setUpProjectRootArtifacts(flavor: flavor); - final exitCode = await runWithOverrides(command.run); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); - - test('does not prompt if unable to accept user input', () async { - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verifyNever(() => logger.confirm(any())); - }); - }); -} diff --git a/packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart similarity index 98% rename from packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart rename to packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart index 748d0534..076910d3 100644 --- a/packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart @@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/deployment_track.dart'; import 'package:shorebird_cli/src/logger.dart'; @@ -85,7 +85,7 @@ void main() { late ShorebirdEnv shorebirdEnv; late ShorebirdFlutter shorebirdFlutter; - late PatchNewCommand command; + late PatchCommand command; R runWithOverrides(R Function() body) { return runScoped( @@ -255,7 +255,7 @@ void main() { ), ).thenAnswer((_) async => {}); - command = PatchNewCommand(resolvePatcher: (_) => patcher) + command = PatchCommand(resolvePatcher: (_) => patcher) ..testArgResults = argResults; }); @@ -263,12 +263,6 @@ void main() { expect(command.description, isNotEmpty); }); - group('hidden', () { - test('is true', () { - expect(command.hidden, true); - }); - }); - group('getPatcher', () { test('maps the correct platform to the patcher', () async { expect( diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart deleted file mode 100644 index 82f654ee..00000000 --- a/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart +++ /dev/null @@ -1,1794 +0,0 @@ -import 'dart:io' hide Platform; - -import 'package:args/args.dart'; -import 'package:collection/collection.dart'; -import 'package:http/http.dart' as http; -import 'package:mason_logger/mason_logger.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; -import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; -import 'package:shorebird_cli/src/artifact_manager.dart'; -import 'package:shorebird_cli/src/auth/auth.dart'; -import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch/patch.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/deployment_track.dart'; -import 'package:shorebird_cli/src/doctor.dart'; -import 'package:shorebird_cli/src/engine_config.dart'; -import 'package:shorebird_cli/src/executables/executables.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/os/operating_system_interface.dart'; -import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/shorebird_artifacts.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_flutter.dart'; -import 'package:shorebird_cli/src/shorebird_process.dart'; -import 'package:shorebird_cli/src/shorebird_validator.dart'; -import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; -import 'package:shorebird_cli/src/validators/validators.dart'; -import 'package:shorebird_cli/src/version.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; -import 'package:test/test.dart'; - -import '../../fakes.dart'; -import '../../mocks.dart'; - -void main() { - const preLinkerFlutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; - const postLinkerFlutterRevision = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; - const flutterVersionAndRevision = '3.10.6 (83305b5088)'; - const appId = 'test-app-id'; - const shorebirdYaml = ShorebirdYaml(appId: appId); - const versionName = '1.2.3'; - const versionCode = '1'; - const version = '$versionName+$versionCode'; - const arch = 'aarch64'; - const operatingSystem = 'macOS'; - const operatingSystemVersion = '11.0.0'; - const xcodeVersion = '12.0'; - const track = DeploymentTrack.production; - const appDisplayName = 'Test App'; - const releasePlatform = ReleasePlatform.ios; - const platformName = 'ios'; - const elfAotSnapshotFileName = 'out.aot'; - const linkFileName = 'out.vmcode'; - const ipaPath = 'build/ios/ipa/Runner.ipa'; - const releaseArtifactFilePath = 'downloads/release.artifact'; - const infoPlistContent = ''' - - - - - ApplicationProperties - - ApplicationPath - Applications/Runner.app - Architectures - - arm64 - - CFBundleIdentifier - com.shorebird.timeShift - CFBundleShortVersionString - 1.2.3 - CFBundleVersion - 1 - - ArchiveVersion - 2 - Name - Runner - SchemeName - Runner - -'''; - const emptyPlistContent = ''' - - - - - ApplicationProperties - - - -' -'''; - const pubspecYamlContent = ''' -name: example -version: $version -environment: - sdk: ">=2.19.0 <3.0.0" - -flutter: - assets: - - shorebird.yaml'''; - - final appMetadata = AppMetadata( - appId: appId, - displayName: appDisplayName, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - const ipaArtifact = ReleaseArtifact( - id: 0, - releaseId: 0, - arch: arch, - platform: ReleasePlatform.ios, - hash: '#', - size: 42, - url: 'https://example.com/release.ipa', - ); - final preLinkerRelease = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.active}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - final postLinkerRelease = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: postLinkerFlutterRevision, - displayName: '1.2.4+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.active}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - - group( - PatchIosCommand, - () { - late AotTools aotTools; - late ArgResults argResults; - late ArtifactManager artifactManager; - late Auth auth; - late CodePushClientWrapper codePushClientWrapper; - late Directory flutterDirectory; - late Directory shorebirdRoot; - late Directory projectRoot; - late EngineConfig engineConfig; - late File genSnapshotFile; - late File analyzeSnapshotFile; - late File releaseArtifactFile; - late ShorebirdArtifacts shorebirdArtifacts; - late Doctor doctor; - late Ios ios; - late IosArchiveDiffer archiveDiffer; - late Progress progress; - late ShorebirdLogger logger; - late OperatingSystemInterface operatingSystemInterface; - late PatchDiffChecker patchDiffChecker; - late Platform platform; - late ShorebirdProcessResult aotBuildProcessResult; - late ShorebirdProcessResult flutterBuildProcessResult; - late ShorebirdProcessResult flutterPubGetProcessResult; - late http.Client httpClient; - late ShorebirdEnv shorebirdEnv; - late ShorebirdFlutter shorebirdFlutter; - late ShorebirdFlutterValidator flutterValidator; - late ShorebirdProcess shorebirdProcess; - late ShorebirdValidator shorebirdValidator; - late XcodeBuild xcodeBuild; - late PatchIosCommand command; - - R runWithOverrides(R Function() body) { - return runScoped( - body, - values: { - aotToolsRef.overrideWith(() => aotTools), - artifactManagerRef.overrideWith(() => artifactManager), - authRef.overrideWith(() => auth), - codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), - doctorRef.overrideWith(() => doctor), - engineConfigRef.overrideWith(() => engineConfig), - iosRef.overrideWith(() => ios), - shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), - loggerRef.overrideWith(() => logger), - osInterfaceRef.overrideWith(() => operatingSystemInterface), - patchDiffCheckerRef.overrideWith(() => patchDiffChecker), - platformRef.overrideWith(() => platform), - processRef.overrideWith(() => shorebirdProcess), - shorebirdEnvRef.overrideWith(() => shorebirdEnv), - shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), - shorebirdValidatorRef.overrideWith(() => shorebirdValidator), - xcodeBuildRef.overrideWith(() => xcodeBuild), - }, - ); - } - - void setUpProjectRoot() { - File( - p.join(projectRoot.path, 'pubspec.yaml'), - ).writeAsStringSync(pubspecYamlContent); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync('app_id: $appId'); - File( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Info.plist', - ), - ) - ..createSync(recursive: true) - ..writeAsStringSync(infoPlistContent); - File( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Products', - 'Applications', - 'Runner.app', - 'Frameworks', - 'App.framework', - 'App', - ), - ).createSync(recursive: true); - File(p.join(projectRoot.path, ipaPath)).createSync(recursive: true); - } - - void setUpProjectRootArtifacts() { - // Create a second app.dill for coverage of newestAppDill file. - File( - p.join( - projectRoot.path, - '.dart_tool', - 'flutter_build', - 'subdir', - 'app.dill', - ), - ).createSync(recursive: true); - File( - p.join(projectRoot.path, '.dart_tool', 'flutter_build', 'app.dill'), - ).createSync(recursive: true); - File( - p.join(projectRoot.path, 'build', elfAotSnapshotFileName), - ).createSync(recursive: true); - File( - p.join(projectRoot.path, 'build', linkFileName), - ).createSync(recursive: true); - } - - setUpAll(() { - registerFallbackValue(CreatePatchMetadata.forTest()); - registerFallbackValue(Directory('')); - registerFallbackValue(File('')); - registerFallbackValue(FileSetDiff.empty()); - registerFallbackValue(ReleasePlatform.ios); - registerFallbackValue(Uri.parse('https://example.com')); - registerFallbackValue(FakeBaseRequest()); - registerFallbackValue(FakeShorebirdProcess()); - registerFallbackValue(DeploymentTrack.production); - }); - - setUp(() { - argResults = MockArgResults(); - artifactManager = MockArtifactManager(); - aotTools = MockAotTools(); - auth = MockAuth(); - codePushClientWrapper = MockCodePushClientWrapper(); - doctor = MockDoctor(); - engineConfig = MockEngineConfig(); - ios = MockIos(); - shorebirdArtifacts = MockShorebirdArtifacts(); - shorebirdRoot = Directory.systemTemp.createTempSync(); - projectRoot = Directory.systemTemp.createTempSync(); - flutterDirectory = Directory( - p.join(shorebirdRoot.path, 'bin', 'cache', 'flutter'), - ); - genSnapshotFile = File( - p.join( - flutterDirectory.path, - 'bin', - 'cache', - 'artifacts', - 'engine', - 'ios-release', - 'gen_snapshot_arm64', - ), - ); - analyzeSnapshotFile = File( - p.join( - flutterDirectory.path, - 'bin', - 'cache', - 'artifacts', - 'engine', - 'android-arm-release', - 'darwin-x64', - 'analyze_snapshot', - ), - )..createSync(recursive: true); - releaseArtifactFile = - File(p.join(projectRoot.path, releaseArtifactFilePath)) - ..createSync(recursive: true); - archiveDiffer = MockIosArchiveDiffer(); - progress = MockProgress(); - logger = MockShorebirdLogger(); - platform = MockPlatform(); - aotBuildProcessResult = MockProcessResult(); - flutterBuildProcessResult = MockProcessResult(); - flutterPubGetProcessResult = MockProcessResult(); - httpClient = MockHttpClient(); - operatingSystemInterface = MockOperatingSystemInterface(); - patchDiffChecker = MockPatchDiffChecker(); - shorebirdEnv = MockShorebirdEnv(); - shorebirdFlutter = MockShorebirdFlutter(); - flutterValidator = MockShorebirdFlutterValidator(); - shorebirdProcess = MockShorebirdProcess(); - shorebirdValidator = MockShorebirdValidator(); - xcodeBuild = MockXcodeBuild(); - - when(() => argResults['allow-asset-diffs']).thenReturn(false); - when(() => argResults['allow-native-diffs']).thenReturn(false); - when(() => argResults['arch']).thenReturn(arch); - when(() => argResults['dry-run']).thenReturn(false); - when(() => argResults['codesign']).thenReturn(true); - when(() => argResults['staging']).thenReturn(false); - when(() => argResults.rest).thenReturn([]); - when(() => argResults.wasParsed(any())).thenReturn(true); - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), - ), - ).thenAnswer((_) async => null); - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => false); - when( - () => aotTools.generatePatchDiffBase( - releaseSnapshot: any(named: 'releaseSnapshot'), - analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), - ), - ).thenAnswer((_) async => File('')); - when(() => artifactManager.downloadFile(any())) - .thenAnswer((_) async => releaseArtifactFile); - when( - () => artifactManager.extractZip( - zipFile: any(named: 'zipFile'), - outputDirectory: any(named: 'outputDirectory'), - ), - ).thenAnswer((invocation) async { - final outputDirectory = - invocation.namedArguments[#outputDirectory] as Directory; - File( - p.join( - outputDirectory.path, - 'Products', - 'Applications', - 'App.app', - 'Frameworks', - 'App.framework', - 'App', - ), - ).createSync(recursive: true); - }); - when( - () => artifactManager.createDiff( - releaseArtifactPath: any(named: 'releaseArtifactPath'), - patchArtifactPath: any(named: 'patchArtifactPath'), - ), - ).thenAnswer((_) async => ''); - when(() => auth.isAuthenticated).thenReturn(true); - when(() => auth.client).thenReturn(httpClient); - when( - () => codePushClientWrapper.getApp(appId: any(named: 'appId')), - ).thenAnswer((_) async => appMetadata); - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => preLinkerRelease); - when( - () => codePushClientWrapper.getReleaseArtifact( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - arch: any(named: 'arch'), - platform: any(named: 'platform'), - ), - ).thenAnswer((_) async => ipaArtifact); - when( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).thenAnswer((_) async {}); - when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); - when(() => engineConfig.localEngine).thenReturn(null); - when( - () => ios.exportOptionsPlistFromArgs(argResults), - ).thenReturn(File('.')); - when(flutterValidator.validate).thenAnswer((_) async => []); - when(() => logger.confirm(any())).thenReturn(true); - when(() => logger.progress(any())).thenReturn(progress); - when( - () => operatingSystemInterface.which('flutter'), - ).thenReturn('/path/to/flutter'); - when(() => platform.operatingSystem).thenReturn(operatingSystem); - when(() => platform.operatingSystemVersion) - .thenReturn(operatingSystemVersion); - when(() => platform.environment).thenReturn({}); - when(() => platform.script).thenReturn(shorebirdRoot.uri); - when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); - when( - () => shorebirdEnv.copyWith( - flutterRevisionOverride: any(named: 'flutterRevisionOverride'), - ), - ).thenAnswer((invocation) { - when(() => shorebirdEnv.flutterRevision).thenReturn( - invocation.namedArguments[#flutterRevisionOverride] as String, - ); - return shorebirdEnv; - }); - when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); - when( - () => shorebirdEnv.getShorebirdProjectRoot(), - ).thenReturn(projectRoot); - when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory); - when( - () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, - ), - ).thenReturn(genSnapshotFile.path); - when( - () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, - ), - ).thenReturn(analyzeSnapshotFile.path); - when(() => shorebirdEnv.flutterRevision) - .thenReturn(preLinkerFlutterRevision); - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); - when( - () => shorebirdFlutter.getVersionAndRevision(), - ).thenAnswer((_) async => flutterVersionAndRevision); - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async {}); - when( - () => aotBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when( - () => flutterBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when(() => flutterPubGetProcessResult.exitCode) - .thenReturn(ExitCode.success.code); - when( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).thenAnswer((_) async => flutterPubGetProcessResult); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - when( - () => shorebirdProcess.run( - any(that: endsWith('gen_snapshot_arm64')), - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => aotBuildProcessResult); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - supportedOperatingSystems: any(named: 'supportedOperatingSystems'), - ), - ).thenAnswer((_) async {}); - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: false, - hasNativeChanges: false, - ), - ); - when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); - - command = runWithOverrides( - () => PatchIosCommand(archiveDiffer: archiveDiffer), - )..testArgResults = argResults; - - when(aotTools.isLinkDebugInfoSupported).thenAnswer((_) async => true); - }); - - test('supports alpha alias', () { - expect(command.aliases, contains('ios-alpha')); - }); - - test('has a description', () { - expect(command.description, isNotEmpty); - }); - - test('exits when validation fails', () async { - final exception = ValidationFailedException(); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - supportedOperatingSystems: any(named: 'supportedOperatingSystems'), - ), - ).thenThrow(exception); - await expectLater( - runWithOverrides(command.run), - completion(equals(exception.exitCode.code)), - ); - verify( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - validators: [flutterValidator], - supportedOperatingSystems: {Platform.macOS}, - ), - ).called(1); - }); - - group('when exportOptionsPlistFromArgs throws exception', () { - setUp(() { - when(() => ios.exportOptionsPlistFromArgs(argResults)) - .thenThrow(ArgumentError('bad args')); - }); - - test('logs error and exits with usage code', () async { - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.usage.code)); - verify(() => logger.err('Invalid argument(s): bad args')).called(1); - }); - }); - - test('exits with code 70 when building fails', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); - - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - }); - - test('exits with code 70 when building fails (due to BuildException)', - () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(0); - when(() => flutterBuildProcessResult.stderr).thenReturn(''' -Encountered error while creating the IPA: -error: exportArchive: Communication with Apple failed -error: exportArchive: No signing certificate "iOS Distribution" found -error: exportArchive: Communication with Apple failed -error: exportArchive: No signing certificate "iOS Distribution" found -error: exportArchive: Team "My Team" does not have permission to create "iOS App Store" provisioning profiles. -error: exportArchive: No profiles for 'com.example.co' were found -error: exportArchive: Communication with Apple failed -error: exportArchive: No signing certificate "iOS Distribution" found -error: exportArchive: Communication with Apple failed -error: exportArchive: No signing certificate "iOS Distribution" found -error: exportArchive: Communication with Apple failed -error: exportArchive: No signing certificate "iOS Distribution" found -'''); - - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail(any(that: contains('Failed to build'))), - ).called(1); - verify( - () => logger.err(''' - Communication with Apple failed - No signing certificate "iOS Distribution" found - Team "My Team" does not have permission to create "iOS App Store" provisioning profiles. - No profiles for 'com.example.co' were found'''), - ).called(1); - }); - - group('when build directory has non-default structure', () { - test('exits with code 70 if xcarchive is not found', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - Directory( - p.join(projectRoot.path, 'build'), - ).deleteSync(recursive: true); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - any(that: contains('Unable to find .xcarchive directory')), - ), - ).called(1); - }); - - test('prints error and exits with code 70 if Info.plist does not exist', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final plistPath = p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Info.plist', - ); - File(plistPath).deleteSync(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err('No Info.plist file found at $plistPath.'), - ).called(1); - }); - - test('finds xcarchive that has been renamed from Runner', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - Directory( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - ), - ).renameSync( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Ø´ŲˆØąØ¨ŲŠØąØ¯ | Shorebird.xcarchive', - ), - ); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - }); - }); - - test( - '''exits with code 70 if release does not exist for the ios platform''', - () async { - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer( - (_) async => Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.android: ReleaseStatus.active}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify(() => logger.err('No iOS release found for 1.2.3+1.')).called(1); - }); - - test( - '''exits with code 70 if release is in draft state for the ios platform''', - () async { - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer( - (_) async => Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify( - () => logger.err(''' -Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''), - ).called(1); - }); - - test('proceeds if release is in draft state for a non-ios platform', - () async { - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer( - (_) async => Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: { - ReleasePlatform.android: ReleaseStatus.draft, - ReleasePlatform.ios: ReleaseStatus.active, - }, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - }); - - test( - '''switches to release flutter revision when shorebird flutter revision does not match''', - () async { - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - - // If a release version is not specified as a command argument, we - // build the app with the default Flutter revision to determine the - // release version. We then build the app with the release Flutter - // revision to determine the release version. - var hasBuiltToDetermineReleaseVersion = false; - var hasRunGenSnapshotToDetermineReleaseVersion = false; - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - if (!hasBuiltToDetermineReleaseVersion) { - hasBuiltToDetermineReleaseVersion = true; - return flutterBuildProcessResult; - } - - // Ensure we're using the correct flutter revision. - expect( - shorebirdEnv.flutterRevision, - equals(preLinkerFlutterRevision), - ); - return flutterBuildProcessResult; - }); - when( - () => shorebirdProcess.run( - any(that: endsWith('gen_snapshot_arm64')), - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - if (!hasRunGenSnapshotToDetermineReleaseVersion) { - hasRunGenSnapshotToDetermineReleaseVersion = true; - return aotBuildProcessResult; - } - - // Ensure we're using the correct flutter revision. - expect( - shorebirdEnv.flutterRevision, - equals(preLinkerFlutterRevision), - ); - return aotBuildProcessResult; - }); - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer((_) async { - // Ensure we're using the correct flutter revision. - expect( - shorebirdEnv.flutterRevision, - equals(preLinkerFlutterRevision), - ); - return null; - }); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, ExitCode.success.code); - - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async {}); - verify( - () => logger.info( - '''Release was built with Flutter revision ${preLinkerRelease.flutterRevision}''', - ), - ).called(1); - }); - - group('when release-version option is provided', () { - const customReleaseVersion = 'custom-release-version'; - - setUp(() { - when( - () => argResults['release-version'], - ).thenReturn(customReleaseVersion); - }); - - test('does not extract release version from archive', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - await runWithOverrides(command.run); - - verify( - () => codePushClientWrapper.getRelease( - appId: appId, - releaseVersion: customReleaseVersion, - ), - ).called(1); - }); - - test('exits with code 70 if xcarchive is not found', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - Directory( - p.join(projectRoot.path, 'build'), - ).deleteSync(recursive: true); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - any(that: contains('Unable to find .xcarchive directory')), - ), - ).called(1); - }); - - test('exits with code 70 if build fails', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - }); - - test('only builds once if release uses different flutter revision', - () async { - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - - verify( - () => shorebirdProcess.run( - 'flutter', - any( - that: containsAll([ - 'build', - 'ipa', - '--release', - ]), - ), - runInShell: any(named: 'runInShell'), - ), - ).called(1); - }); - }); - - test('exits with code 70 when release version cannot be determined', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final file = File( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Info.plist', - ), - ) - ..createSync(recursive: true) - ..writeAsStringSync(emptyPlistContent); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - 'Failed to determine release version from ${file.path}: ' - 'Exception: Could not determine release version', - ), - ).called(1); - }); - - test('prints release version when detected', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify(() => logger.info('Detected release version 1.2.3+1')).called(1); - }); - - test('aborts when user opts out', () async { - when(() => logger.confirm(any())).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify(() => logger.info('Aborting.')).called(1); - }); - - group('when flutter version install fails', () { - setUp(() { - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenThrow(Exception('oops')); - }); - - test('exits with code 70', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); - verify( - () => shorebirdFlutter.installRevision( - revision: preLinkerFlutterRevision, - ), - ).called(1); - }); - }); - - test('throws error when creating aot snapshot fails', () async { - const error = 'oops something went wrong'; - when(() => aotBuildProcessResult.exitCode).thenReturn(1); - when(() => aotBuildProcessResult.stderr).thenReturn(error); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => progress.fail('Exception: Failed to create snapshot: $error'), - ).called(1); - expect(exitCode, ExitCode.software.code); - }); - - test( - '''exits with code 0 if zipAndConfirmUnpatchableDiffsIfNecessary throws UserCancelledException''', - () async { - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UserCancelledException()); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test('exits with code 70 if release artifact fails to download', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - releaseArtifactFile.deleteSync(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail('Exception: Failed to download release artifact'), - ).called(1); - }); - - group('when release artifact fails to extract', () { - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - when( - () => artifactManager.extractZip( - zipFile: any(named: 'zipFile'), - outputDirectory: any(named: 'outputDirectory'), - ), - ).thenAnswer((invocation) async {}); - }); - - test('prints error message and exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err('Unable to find release artifact .app directory'), - ).called(1); - }); - }); - - test( - '''exits with code 70 if zipAndConfirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''', - () async { - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UnpatchableChangeException()); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - group('when the engine revision is pre-linker', () { - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - }); - - test('we do not attempt to link the AOT file', () async { - await runWithOverrides(command.run); - - verifyNever( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ); - verifyNever(() => aotTools.isGeneratePatchDiffBaseSupported()); - }); - }); - - group('when the engine revision supports the linker', () { - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => postLinkerRelease); - }); - - group('when using a local engine build', () { - setUp(() { - when(() => engineConfig.localEngine).thenReturn('engine'); - }); - - test('attempts to link', () async { - await runWithOverrides(command.run); - - verify( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).called(1); - }); - }); - - test('we attempt to link the AOT file', () async { - await runWithOverrides(command.run); - verify( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).called(1); - }); - - group('when patch AOT file is not found', () { - test('exits with code 70', () async { - final patch = File( - p.join(projectRoot.path, 'build', elfAotSnapshotFileName), - )..deleteSync(recursive: true); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => - logger.err('Unable to find patch AOT file at ${patch.path}'), - ).called(1); - }); - }); - - group('when analyze snapshot is not found', () { - setUp(() { - analyzeSnapshotFile.deleteSync(recursive: true); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - '''Unable to find analyze_snapshot at ${analyzeSnapshotFile.path}''', - ), - ).called(1); - }); - }); - - group('when linking fails', () { - final exception = Exception('failed to link'); - setUp(() { - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenThrow(exception); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail('Failed to link AOT files: $exception'), - ).called(1); - }); - }); - - group('when aot_tools returns a low link percentage', () { - setUp(() { - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer((_) async => PatchIosCommand.minLinkPercentage - 1); - }); - - test('logs a warning', () async { - await runWithOverrides(command.run); - verify( - () => logger.warn( - PatchIosCommand.lowLinkPercentageWarning( - PatchIosCommand.minLinkPercentage - 1, - ), - ), - ).called(1); - }); - }); - - group('when debug-linker is true', () { - test( - 'succeeds and create debug info zip', - () async { - when(() => argResults['debug-linker']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), - ), - ).called(1); - }, - ); - - test( - 'prints the file location when linking is less than the minimum', - () async { - const notEnoughLinkPercentage = 60.0; - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), - ), - ).thenAnswer( - (_) async => notEnoughLinkPercentage, - ); - when(() => argResults['debug-linker']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - - final captured = verify(() => logger.info(captureAny())).captured - as List; - final contains = captured.whereType().any( - (element) => element.contains('Debug Info'), - ); - expect(contains, isTrue); - }, - ); - - group("when aot-tools don't support debugging the link command", () { - test( - "don't debug even when the flag is true", - () async { - when(() => argResults['debug-linker']).thenReturn(true); - when(aotTools.isLinkDebugInfoSupported).thenAnswer( - (_) async => false, - ); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).called(1); - }, - ); - }); - }); - }); - - group('when aot-tools supports generating patch diff base', () { - const diffPath = 'path/to/diff'; - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => postLinkerRelease); - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => true); - when( - () => artifactManager.createDiff( - releaseArtifactPath: any(named: 'releaseArtifactPath'), - patchArtifactPath: any(named: 'patchArtifactPath'), - ), - ).thenAnswer((_) async => diffPath); - }); - - group('when generatePatchDiffBase fails', () { - const errorMessage = 'oops something went wrong'; - setUp(() { - when( - () => aotTools.generatePatchDiffBase( - analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), - releaseSnapshot: any(named: 'releaseSnapshot'), - ), - ).thenThrow(Exception(errorMessage)); - }); - - test('prints error and exits with code 70', () async { - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); - verify(() => progress.fail('Exception: $errorMessage')).called(1); - }); - }); - - test('generates diff base and publishes the appropriate patch', - () async { - const linkPercentage = 99.9; - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer( - (_) async => linkPercentage, - ); - await runWithOverrides(command.run); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: postLinkerRelease.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any( - named: 'patchArtifactBundles', - that: isA>() - .having((e) => e[Arch.arm64]!.path, 'patch path', diffPath), - ), - metadata: const CreatePatchMetadata( - releasePlatform: releasePlatform, - usedIgnoreAssetChangesFlag: false, - hasAssetChanges: false, - usedIgnoreNativeChangesFlag: false, - hasNativeChanges: false, - linkPercentage: linkPercentage, - environment: BuildEnvironmentMetadata( - shorebirdVersion: packageVersion, - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - xcodeVersion: xcodeVersion, - ), - ), - ), - ).called(1); - }); - }); - - test('does not create patch on --dry-run', () async { - when(() => argResults['dry-run']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.success.code)); - verifyNever( - () => codePushClientWrapper.createPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - metadata: any(named: 'metadata'), - ), - ); - verify(() => logger.info('No issues detected.')).called(1); - }); - - test( - '''forwards allow-asset-diffs and allow-native-diffs to patch diff checker''', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - when(() => argResults['allow-asset-diffs']).thenReturn(true); - when(() => argResults['allow-native-diffs']).thenReturn(true); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: true, - allowNativeChanges: true, - ), - ).called(1); - - when(() => argResults['allow-asset-diffs']).thenReturn(false); - when(() => argResults['allow-native-diffs']).thenReturn(false); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - }); - - test('reports when patch has asset and native changes', () async { - when(() => argResults['allow-asset-diffs']).thenReturn(true); - when(() => argResults['allow-native-diffs']).thenReturn(true); - when(() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.changedFiles(any(), any())) - .thenAnswer((_) async => FileSetDiff.empty()); - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: true, - hasNativeChanges: true, - ), - ); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any( - named: 'metadata', - that: isA() - .having( - (m) => m.releasePlatform, - 'releasePlatform', - releasePlatform, - ) - .having( - (m) => m.hasAssetChanges, - 'hasAssetChanges', - true, - ) - .having( - (m) => m.hasNativeChanges, - 'hasNativeChanges', - true, - ), - ), - ), - ).called(1); - }); - - test('succeeds when patch is successful (production)', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => logger.info( - any( - that: contains( - ''' -đŸ•šī¸ Platform: ${lightCyan.wrap(platformName)} ${lightCyan.wrap('[aarch64 (0 B)]')} -đŸŸĸ Track: ${lightCyan.wrap('Production')}''', - ), - ), - ), - ).called(1); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).called(1); - - // Verify that an export options plist was provided to the build ipa - // command. - const exportOptionsPlistArgName = 'export-options-plist'; - final capturedArgs = verify( - () => shorebirdProcess.run( - 'flutter', - captureAny(), - runInShell: any(named: 'runInShell'), - ), - ).captured.first as List; - final exportOptionsPlistArg = capturedArgs - .whereType() - .firstWhereOrNull((arg) => arg.contains(exportOptionsPlistArgName)); - expect(exportOptionsPlistArg, isNotNull); - - expect(exitCode, ExitCode.success.code); - }); - - test('succeeds when patch is successful (staging)', () async { - when(() => argResults['staging']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => logger.info( - any( - that: contains( - ''' -đŸ•šī¸ Platform: ${lightCyan.wrap(platformName)} ${lightCyan.wrap('[aarch64 (0 B)]')} -🟠 Track: ${lightCyan.wrap('Staging')}''', - ), - ), - ), - ).called(1); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: releasePlatform, - track: DeploymentTrack.staging, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); - - test('runs flutter pub get with system flutter after successful build', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - await runWithOverrides(command.run); - - verify( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).called(1); - }); - - test('forwards codesign to flutter build', () async { - when(() => argResults['codesign']).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - await runWithOverrides(command.run); - - verify( - () => shorebirdProcess.run( - 'flutter', - any( - that: containsAllInOrder( - [ - 'build', - 'ipa', - '--release', - '--no-codesign', - ], - ), - ), - runInShell: true, - ), - ).called(1); - }); - - test('does not provide export options when codesign is false', () async { - when(() => argResults['codesign']).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - await runWithOverrides(command.run); - - final capturedArgs = verify( - () => shorebirdProcess.run( - 'flutter', - captureAny(), - runInShell: any(named: 'runInShell'), - ), - ).captured.first as List; - expect( - capturedArgs - .whereType() - .firstWhereOrNull((arg) => arg.contains('export-options-plist')), - isNull, - ); - }); - - test('succeeds when patch is successful with flavors and target', - () async { - const flavor = 'development'; - const target = './lib/main_development.dart'; - when(() => argResults['flavor']).thenReturn(flavor); - when(() => argResults['target']).thenReturn(target); - setUpProjectRoot(); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync(''' -app_id: productionAppId -flavors: - development: $appId'''); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: const CreatePatchMetadata( - releasePlatform: releasePlatform, - usedIgnoreAssetChangesFlag: false, - hasAssetChanges: false, - usedIgnoreNativeChangesFlag: false, - hasNativeChanges: false, - linkPercentage: null, - environment: BuildEnvironmentMetadata( - shorebirdVersion: packageVersion, - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - xcodeVersion: xcodeVersion, - ), - ), - ), - ).called(1); - }); - - test('succeeds when patch is successful using custom base_url', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - const baseUrl = 'https://example.com'; - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync( - ''' -app_id: $appId -base_url: $baseUrl''', - ); - await runWithOverrides(command.run); - }); - - test('does not prompt if unable to accept user input', () async { - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verifyNever(() => logger.confirm(any())); - }); - }, - testOn: 'mac-os', - ); -} diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_ios_framework_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_ios_framework_command_test.dart deleted file mode 100644 index 0d74da18..00000000 --- a/packages/shorebird_cli/test/src/commands/patch/patch_ios_framework_command_test.dart +++ /dev/null @@ -1,1216 +0,0 @@ -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:mason_logger/mason_logger.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; -import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; -import 'package:shorebird_cli/src/artifact_manager.dart'; -import 'package:shorebird_cli/src/auth/auth.dart'; -import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch/patch.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/deployment_track.dart'; -import 'package:shorebird_cli/src/doctor.dart'; -import 'package:shorebird_cli/src/engine_config.dart'; -import 'package:shorebird_cli/src/executables/executables.dart'; -import 'package:shorebird_cli/src/logger.dart'; -import 'package:shorebird_cli/src/os/operating_system_interface.dart'; -import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform.dart'; -import 'package:shorebird_cli/src/platform/platform.dart'; -import 'package:shorebird_cli/src/shorebird_artifacts.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_flutter.dart'; -import 'package:shorebird_cli/src/shorebird_process.dart'; -import 'package:shorebird_cli/src/shorebird_validator.dart'; -import 'package:shorebird_cli/src/validators/validators.dart'; -import 'package:shorebird_cli/src/version.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; -import 'package:test/test.dart'; - -import '../../mocks.dart'; - -void main() { - group( - PatchIosFrameworkCommand, - () { - const appDisplayName = 'Test App'; - const appId = 'test-app-id'; - const shorebirdYaml = ShorebirdYaml(appId: appId); - const versionName = '1.2.3'; - const versionCode = '1'; - const track = DeploymentTrack.production; - const version = '$versionName+$versionCode'; - const linkFileName = 'out.vmcode'; - const elfAotSnapshotFileName = 'out.aot'; - const operatingSystem = 'macOS'; - const operatingSystemVersion = '11.0.0'; - const xcodeVersion = '12.0'; - const postLinkerFlutterRevision = - 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; - const preLinkerFlutterRevision = - '83305b5088e6fe327fb3334a73ff190828d85713'; - const flutterVersionAndRevision = '3.10.6 (83305b5088)'; - const pubspecYamlContent = ''' -name: example -version: $version -environment: - sdk: ">=2.19.0 <3.0.0" - -flutter: - assets: - - shorebird.yaml'''; - final appMetadata = AppMetadata( - appId: appId, - displayName: appDisplayName, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - const arch = 'aarch64'; - const xcframeworkArtifact = ReleaseArtifact( - id: 0, - releaseId: 0, - arch: arch, - platform: ReleasePlatform.ios, - hash: '#', - size: 42, - url: 'https://example.com/release.xcframework', - ); - final preLinkerRelease = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - final postLinkerRelease = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: postLinkerFlutterRevision, - displayName: '1.2.4+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.active}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - - late File releaseArtifactFile; - - late AotTools aotTools; - late ArgResults argResults; - late ArtifactManager artifactManager; - late CodePushClientWrapper codePushClientWrapper; - late Directory shorebirdRoot; - late Directory projectRoot; - late Directory flutterDirectory; - late EngineConfig engineConfig; - late File analyzeSnapshotFile; - late File genSnapshotFile; - late ShorebirdArtifacts shorebirdArtifacts; - late Doctor doctor; - late IosArchiveDiffer archiveDiffer; - late PatchDiffChecker patchDiffChecker; - late Platform platform; - late Auth auth; - late OperatingSystemInterface operatingSystemInterface; - late ShorebirdLogger logger; - late Progress progress; - late ShorebirdProcessResult aotBuildProcessResult; - late ShorebirdProcessResult flutterBuildProcessResult; - late ShorebirdProcessResult flutterPubGetProcessResult; - late ShorebirdEnv shorebirdEnv; - late ShorebirdFlutter shorebirdFlutter; - late ShorebirdFlutterValidator flutterValidator; - late ShorebirdProcess shorebirdProcess; - late ShorebirdValidator shorebirdValidator; - late XcodeBuild xcodeBuild; - late PatchIosFrameworkCommand command; - - R runWithOverrides(R Function() body) { - return runScoped( - body, - values: { - aotToolsRef.overrideWith(() => aotTools), - artifactManagerRef.overrideWith(() => artifactManager), - authRef.overrideWith(() => auth), - codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), - doctorRef.overrideWith(() => doctor), - engineConfigRef.overrideWith(() => engineConfig), - shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), - loggerRef.overrideWith(() => logger), - osInterfaceRef.overrideWith(() => operatingSystemInterface), - patchDiffCheckerRef.overrideWith(() => patchDiffChecker), - platformRef.overrideWith(() => platform), - processRef.overrideWith(() => shorebirdProcess), - shorebirdEnvRef.overrideWith(() => shorebirdEnv), - shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), - shorebirdValidatorRef.overrideWith(() => shorebirdValidator), - xcodeBuildRef.overrideWith(() => xcodeBuild), - }, - ); - } - - void setUpProjectRootArtifacts() { - // Create a second app.dill for coverage of newestAppDill file. - File( - p.join( - projectRoot.path, - '.dart_tool', - 'flutter_build', - 'subdir', - 'app.dill', - ), - ).createSync(recursive: true); - File( - p.join(projectRoot.path, '.dart_tool', 'flutter_build', 'app.dill'), - ).createSync(recursive: true); - File(p.join(projectRoot.path, 'build', elfAotSnapshotFileName)) - .createSync( - recursive: true, - ); - Directory( - p.join( - projectRoot.path, - 'build', - 'ios', - 'framework', - 'Release', - 'App.xcframework', - ), - ).createSync( - recursive: true, - ); - File( - p.join(projectRoot.path, 'build', linkFileName), - ).createSync(recursive: true); - } - - void setUpProjectRoot() { - File( - p.join(projectRoot.path, 'pubspec.yaml'), - ).writeAsStringSync(pubspecYamlContent); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync('app_id: $appId'); - } - - setUpAll(() { - registerFallbackValue(CreatePatchMetadata.forTest()); - registerFallbackValue(Directory('')); - registerFallbackValue(File('')); - registerFallbackValue(FileSetDiff.empty()); - registerFallbackValue(ReleasePlatform.ios); - registerFallbackValue(Uri.parse('https://example.com')); - registerFallbackValue(DeploymentTrack.production); - }); - - setUp(() { - aotTools = MockAotTools(); - argResults = MockArgResults(); - archiveDiffer = MockIosArchiveDiffer(); - artifactManager = MockArtifactManager(); - codePushClientWrapper = MockCodePushClientWrapper(); - doctor = MockDoctor(); - engineConfig = MockEngineConfig(); - shorebirdArtifacts = MockShorebirdArtifacts(); - patchDiffChecker = MockPatchDiffChecker(); - platform = MockPlatform(); - xcodeBuild = MockXcodeBuild(); - shorebirdRoot = Directory.systemTemp.createTempSync(); - projectRoot = Directory.systemTemp.createTempSync(); - flutterDirectory = Directory( - p.join(shorebirdRoot.path, 'bin', 'cache', 'flutter'), - ); - genSnapshotFile = File( - p.join( - flutterDirectory.path, - 'bin', - 'cache', - 'artifacts', - 'engine', - 'ios-release', - 'gen_snapshot_arm64', - ), - ); - analyzeSnapshotFile = File( - p.join( - flutterDirectory.path, - 'bin', - 'cache', - 'artifacts', - 'engine', - 'ios-release', - 'analyze_snapshot_arm64', - ), - )..createSync(recursive: true); - auth = MockAuth(); - progress = MockProgress(); - logger = MockShorebirdLogger(); - aotBuildProcessResult = MockProcessResult(); - flutterBuildProcessResult = MockProcessResult(); - flutterPubGetProcessResult = MockProcessResult(); - operatingSystemInterface = MockOperatingSystemInterface(); - shorebirdEnv = MockShorebirdEnv(); - shorebirdFlutter = MockShorebirdFlutter(); - flutterValidator = MockShorebirdFlutterValidator(); - shorebirdProcess = MockShorebirdProcess(); - shorebirdValidator = MockShorebirdValidator(); - - when( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).thenAnswer((_) async => flutterPubGetProcessResult); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - when( - () => shorebirdProcess.run( - any(that: endsWith('gen_snapshot_arm64')), - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => aotBuildProcessResult); - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => false); - when( - () => aotTools.generatePatchDiffBase( - releaseSnapshot: any(named: 'releaseSnapshot'), - analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), - ), - ).thenAnswer((_) async => File('')); - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer((_) async => null); - when(() => argResults['release-version']).thenReturn(version); - when(() => argResults.rest).thenReturn([]); - when(() => artifactManager.downloadFile(any())).thenAnswer((_) async { - final tmpDir = Directory.systemTemp.createTempSync(); - return releaseArtifactFile = - File(p.join(tmpDir.path, 'release.artifact')) - ..createSync(recursive: true); - }); - when( - () => artifactManager.extractZip( - zipFile: any(named: 'zipFile'), - outputDirectory: any(named: 'outputDirectory'), - ), - ).thenAnswer((_) async {}); - when(() => auth.isAuthenticated).thenReturn(true); - when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); - when(() => engineConfig.localEngine).thenReturn(null); - when(flutterValidator.validate).thenAnswer((_) async => []); - when(() => logger.level).thenReturn(Level.info); - when(() => logger.progress(any())).thenReturn(progress); - when(() => logger.confirm(any())).thenReturn(true); - when( - () => operatingSystemInterface.which('flutter'), - ).thenReturn('/path/to/flutter'); - when(() => platform.operatingSystem).thenReturn(operatingSystem); - when(() => platform.operatingSystemVersion) - .thenReturn(operatingSystemVersion); - when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); - when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); - when( - () => shorebirdEnv.getShorebirdProjectRoot(), - ).thenReturn(projectRoot); - when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory); - when( - () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, - ), - ).thenReturn(analyzeSnapshotFile.path); - when( - () => shorebirdFlutter.getVersionAndRevision(), - ).thenAnswer((_) async => flutterVersionAndRevision); - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async {}); - when( - () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, - ), - ).thenReturn(genSnapshotFile.path); - when(() => shorebirdEnv.flutterRevision) - .thenReturn(preLinkerFlutterRevision); - when( - () => shorebirdEnv.copyWith( - flutterRevisionOverride: any(named: 'flutterRevisionOverride'), - ), - ).thenAnswer((invocation) { - when(() => shorebirdEnv.flutterRevision).thenReturn( - invocation.namedArguments[#flutterRevisionOverride] as String, - ); - return shorebirdEnv; - }); - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); - when( - () => aotBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when( - () => flutterBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when(() => flutterPubGetProcessResult.exitCode) - .thenReturn(ExitCode.success.code); - when( - () => codePushClientWrapper.getApp(appId: any(named: 'appId')), - ).thenAnswer((_) async => appMetadata); - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer((_) async => [preLinkerRelease]); - when( - () => codePushClientWrapper.getReleaseArtifact( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - arch: any(named: 'arch'), - platform: any(named: 'platform'), - ), - ).thenAnswer((_) async => xcframeworkArtifact); - when( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).thenAnswer((_) async {}); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - supportedOperatingSystems: any(named: 'supportedOperatingSystems'), - ), - ).thenAnswer((_) async {}); - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: false, - hasNativeChanges: false, - ), - ); - when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); - - command = runWithOverrides( - () => PatchIosFrameworkCommand(archiveDiffer: archiveDiffer), - )..testArgResults = argResults; - }); - - test('supports alpha alias', () { - expect(command.aliases, contains('ios-framework-alpha')); - }); - - test('has a description', () { - expect(command.description, isNotEmpty); - }); - - test('exits when validation fails', () async { - final exception = ValidationFailedException(); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - supportedOperatingSystems: any(named: 'supportedOperatingSystems'), - ), - ).thenThrow(exception); - await expectLater( - runWithOverrides(command.run), - completion(equals(exception.exitCode.code)), - ); - verify( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - validators: [flutterValidator], - supportedOperatingSystems: {Platform.macOS}, - ), - ).called(1); - }); - - test('prompts for release when release-version is not specified', - () async { - when(() => argResults['release-version']).thenReturn(null); - when( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: any(named: 'display'), - ), - ).thenReturn(preLinkerRelease); - try { - await runWithOverrides(command.run); - } catch (_) {} - await untilCalled( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: any(named: 'display'), - ), - ); - final display = verify( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: captureAny(named: 'display'), - ), - ).captured.single as String Function(Release); - expect(display(preLinkerRelease), equals(preLinkerRelease.version)); - }); - - test('exits early when no releases are found', () async { - when(() => argResults['release-version']).thenReturn(null); - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer((_) async => []); - try { - await runWithOverrides(command.run); - } catch (_) {} - verifyNever( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: captureAny(named: 'display'), - ), - ); - verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); - verify(() => logger.info('No releases found')).called(1); - }); - - test('exits early when specified release does not exist.', () async { - when(() => argResults['release-version']).thenReturn('0.0.0'); - try { - await runWithOverrides(command.run); - } catch (_) {} - verifyNever( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: captureAny(named: 'display'), - ), - ); - verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); - verify( - () => logger.info(''' -No release found for version 0.0.0 - -Available release versions: -${preLinkerRelease.version}'''), - ).called(1); - }); - - test( - '''exits with code 70 if release is in draft state for the ios platform''', - () async { - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer( - (_) async => [ - Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ], - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify( - () => logger.err(''' -Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''), - ).called(1); - }); - - test('proceeds if release is in draft state for a non-ios platform', - () async { - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer( - (_) async => [ - Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.android: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ], - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - }); - - test( - '''uses release flutter revision if different than default flutter revision''', - () async { - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer((_) async { - expect( - shorebirdEnv.flutterRevision, - equals(preLinkerFlutterRevision), - ); - return null; - }); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - expect( - shorebirdEnv.flutterRevision, - equals(preLinkerFlutterRevision), - ); - return flutterBuildProcessResult; - }); - when( - () => shorebirdProcess.run( - any(that: endsWith('gen_snapshot_arm64')), - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - expect( - shorebirdEnv.flutterRevision, - equals(preLinkerFlutterRevision), - ); - return aotBuildProcessResult; - }); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => shorebirdFlutter.installRevision( - revision: preLinkerFlutterRevision, - ), - ).called(1); - }); - - test( - 'builds using correct flutter revision ' - 'when release flutter revision differs', () async { - when( - () => platform.script, - ).thenReturn( - Uri.file(p.join('bin', 'cache', 'shorebird.snapshot')), - ); - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - final processWrapper = MockProcessWrapper(); - when( - () => processWrapper.run( - any(), - any(), - runInShell: any(named: 'runInShell'), - workingDirectory: any(named: 'workingDirectory'), - environment: any(named: 'environment'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - final flutterFile = File( - p.join( - '.', - 'bin', - 'cache', - 'flutter', - preLinkerRelease.flutterRevision, - 'bin', - 'flutter', - ), - ); - when(() => shorebirdEnv.flutterBinaryFile).thenReturn(flutterFile); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - await runWithOverrides( - () => runScoped( - () => command.run(), - values: { - processRef.overrideWith( - () => ShorebirdProcess(processWrapper: processWrapper), - ), - }, - ), - ); - - verify( - () => shorebirdFlutter.installRevision( - revision: preLinkerFlutterRevision, - ), - ).called(1); - verify( - () => processWrapper.run( - flutterFile.path, - any(), - runInShell: true, - workingDirectory: any(named: 'workingDirectory'), - environment: any(named: 'environment'), - ), - ).called(1); - }); - - group('when flutter version install fails', () { - setUp(() { - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenThrow(Exception('oops')); - }); - - test('exits with code 70', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); - verify( - () => shorebirdFlutter.installRevision( - revision: preLinkerFlutterRevision, - ), - ).called(1); - }); - }); - - test('aborts when user opts out', () async { - when(() => logger.confirm(any())).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify(() => logger.info('Aborting.')).called(1); - }); - - test('exits with code 70 when build fails', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oh no'); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify(() => progress.fail('Failed to build: oh no')).called(1); - }); - - test('throws error when creating aot snapshot fails', () async { - const error = 'oops something went wrong'; - when(() => aotBuildProcessResult.exitCode).thenReturn(1); - when(() => aotBuildProcessResult.stderr).thenReturn(error); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => progress.fail('Exception: Failed to create snapshot: $error'), - ).called(1); - expect(exitCode, ExitCode.software.code); - }); - - test( - '''exits with code 0 if zipAndConfirmUnpatchableDiffsIfNecessary throws UserCancelledException''', - () async { - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UserCancelledException()); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test( - '''exits with code 70 if zipAndConfirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''', - () async { - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UnpatchableChangeException()); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test('does not create patch on --dry-run', () async { - when(() => argResults['dry-run']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.success.code)); - verifyNever( - () => codePushClientWrapper.createPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - metadata: any(named: 'metadata'), - ), - ); - verify(() => logger.info('No issues detected.')).called(1); - }); - - test( - '''forwards allow-asset-diffs and allow-native-diffs to patch diff checker''', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - when(() => argResults['allow-asset-diffs']).thenReturn(true); - when(() => argResults['allow-native-diffs']).thenReturn(true); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: true, - allowNativeChanges: true, - ), - ).called(1); - - when(() => argResults['allow-asset-diffs']).thenReturn(false); - when(() => argResults['allow-native-diffs']).thenReturn(false); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - }); - - test('reports when patch has asset and native changes', () async { - when(() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.changedFiles(any(), any())) - .thenAnswer((_) async => FileSetDiff.empty()); - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: true, - hasNativeChanges: true, - ), - ); - - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: postLinkerRelease.id, - platform: ReleasePlatform.ios, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any( - named: 'metadata', - that: isA() - .having( - (m) => m.releasePlatform, - 'releasePlatform', - ReleasePlatform.ios, - ) - .having( - (m) => m.hasAssetChanges, - 'hasAssetChanges', - true, - ) - .having( - (m) => m.hasNativeChanges, - 'hasNativeChanges', - true, - ), - ), - ), - ).called(1); - }); - - test('succeeds when patch is successful', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => logger.info( - any( - that: contains( - ''' -đŸ•šī¸ Platform: ${lightCyan.wrap('ios')} ${lightCyan.wrap('[aarch64 (0 B)]')} -đŸŸĸ Track: ${lightCyan.wrap('Production')}''', - ), - ), - ), - ).called(1); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: ReleasePlatform.ios, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: const CreatePatchMetadata( - releasePlatform: ReleasePlatform.ios, - usedIgnoreAssetChangesFlag: false, - hasAssetChanges: false, - usedIgnoreNativeChangesFlag: false, - hasNativeChanges: false, - linkPercentage: null, - environment: BuildEnvironmentMetadata( - shorebirdVersion: packageVersion, - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - xcodeVersion: xcodeVersion, - ), - ), - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); - - test('runs flutter pub get with system flutter after successful build', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - await runWithOverrides(command.run); - - verify( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).called(1); - }); - - test('does not prompt if unable to accept user input', () async { - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verifyNever(() => logger.confirm(any())); - }); - - group('when the engine revision supports the linker', () { - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - when( - () => codePushClientWrapper.getReleases( - appId: any(named: 'appId'), - ), - ).thenAnswer((_) async => [postLinkerRelease]); - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => postLinkerRelease); - }); - - group('when using a local engine build', () { - setUp(() { - when(() => engineConfig.localEngine).thenReturn('engine'); - }); - - test('attempts to link', () async { - await runWithOverrides(command.run); - - verify( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).called(1); - }); - }); - - test('attempts to link the AOT file', () async { - await runWithOverrides(command.run); - verify( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).called(1); - }); - - group('when patch AOT file is not found', () { - test('exits with code 70', () async { - final patch = File( - p.join(projectRoot.path, 'build', elfAotSnapshotFileName), - )..deleteSync(recursive: true); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => - logger.err('Unable to find patch AOT file at ${patch.path}'), - ).called(1); - }); - }); - - group('when analyze snapshot is not found', () { - setUp(() { - analyzeSnapshotFile.deleteSync(recursive: true); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - '''Unable to find analyze_snapshot at ${analyzeSnapshotFile.path}''', - ), - ).called(1); - }); - }); - - group('when linking fails', () { - final exception = Exception('failed to link'); - setUp(() { - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenThrow(exception); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail('Failed to link AOT files: $exception'), - ).called(1); - }); - }); - }); - - group('when aot-tools supports generating patch diff base', () { - const diffPath = 'path/to/diff'; - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => true); - when( - () => artifactManager.createDiff( - releaseArtifactPath: any(named: 'releaseArtifactPath'), - patchArtifactPath: any(named: 'patchArtifactPath'), - ), - ).thenAnswer((_) async => diffPath); - }); - - group('when release artifact fails to download', () { - setUp(() { - when(() => artifactManager.downloadFile(any())) - .thenAnswer((_) async => File('')); - }); - - test('prints error and exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress - .fail('Exception: Failed to download release artifact'), - ).called(1); - }); - }); - - group('when generatePatchDiffBase errors', () { - const errorMessage = 'oops something went wrong'; - setUp(() { - when( - () => aotTools.generatePatchDiffBase( - releaseSnapshot: any(named: 'releaseSnapshot'), - analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), - ), - ).thenThrow(Exception(errorMessage)); - }); - - test('prints error and exits with code 70', () async { - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); - verify(() => progress.fail('Exception: $errorMessage')).called(1); - }); - }); - - test('generates diff base and publishes the appropriate patch', - () async { - await runWithOverrides(command.run); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: ReleasePlatform.ios, - track: track, - patchArtifactBundles: any( - named: 'patchArtifactBundles', - that: isA>() - .having((e) => e[Arch.arm64]!.path, 'patch path', diffPath), - ), - metadata: any(named: 'metadata'), - ), - ).called(1); - }); - }); - }, - testOn: 'mac-os', - ); -} diff --git a/packages/shorebird_cli/test/src/mocks.dart b/packages/shorebird_cli/test/src/mocks.dart index b4864a1d..c602b167 100644 --- a/packages/shorebird_cli/test/src/mocks.dart +++ b/packages/shorebird_cli/test/src/mocks.dart @@ -16,7 +16,7 @@ import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/cache.dart' show Cache; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; import 'package:shorebird_cli/src/commands/release/releaser.dart'; import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/doctor.dart';