diff --git a/packages/shorebird_cli/lib/src/command_runner.dart b/packages/shorebird_cli/lib/src/command_runner.dart index 58d4a5d5..244e1a94 100644 --- a/packages/shorebird_cli/lib/src/command_runner.dart +++ b/packages/shorebird_cli/lib/src/command_runner.dart @@ -6,6 +6,7 @@ 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/commands/release_new/release_new.dart'; import 'package:shorebird_cli/src/engine_config.dart'; import 'package:shorebird_cli/src/logger.dart'; @@ -74,6 +75,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { addCommand(LoginCiCommand()); addCommand(LogoutCommand()); addCommand(PatchCommand()); + addCommand(PatchNewCommand()); addCommand(PreviewCommand()); addCommand(ReleaseCommand()); addCommand(ReleaseNewCommand()); diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/android_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch_new/android_patcher.dart new file mode 100644 index 00000000..9f9d1b7c --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patch_new/android_patcher.dart @@ -0,0 +1,165 @@ +import 'package:crypto/crypto.dart'; +import 'package:io/io.dart'; +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/archive_analysis/android_archive_differ.dart'; +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/release_new/release_type.dart'; +import 'package:shorebird_cli/src/doctor.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/platform/platform.dart'; +import 'package:shorebird_cli/src/shorebird_android_artifacts.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; + +/// {@template android_patcher} +/// Functions to create an Android patch. +/// {@endtemplate} +class AndroidPatcher extends Patcher { + /// {@macro android_patcher} + AndroidPatcher({required super.flavor, required super.target}); + + @override + ReleaseType get releaseType => ReleaseType.android; + + @override + String get primaryReleaseArtifactArch => 'aab'; + + @override + ArchiveDiffer get archiveDiffer => AndroidArchiveDiffer(); + + @override + Future assertPreconditions() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: doctor.androidCommandValidators, + ); + } on PreconditionFailedException catch (e) { + exit(e.exitCode.code); + } + } + + @override + Future buildPatchArtifact() async { + final File aabFile; + final flutterVersionString = await shorebirdFlutter.getVersionAndRevision(); + final buildProgress = + logger.progress('Building patch with Flutter $flutterVersionString'); + + try { + aabFile = + await artifactBuilder.buildAppBundle(flavor: flavor, target: target); + buildProgress.complete(); + } on ArtifactBuildException catch (error) { + buildProgress.fail(error.message); + exit(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''', + ); + exit(ExitCode.software.code); + } + return aabFile; + } + + @override + Future> createPatchArtifacts({ + required String appId, + required int releaseId, + }) async { + final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts( + appId: appId, + releaseId: releaseId, + architectures: AndroidArch.availableAndroidArchs, + platform: releaseType.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'); + exit(ExitCode.software.code); + } + } + + downloadReleaseArtifactProgress.complete(); + + final patchArchsBuildDir = ArtifactManager.androidArchsDirectory( + projectRoot: projectRoot, + flavor: flavor, + ); + if (patchArchsBuildDir == null) { + logger.err('Could not find patch artifacts'); + exit(ExitCode.software.code); + } + + final patchArtifactBundles = {}; + final createDiffProgress = logger.progress('Creating patch 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 = sha256.convert(await patchArtifact.readAsBytes()).toString(); + 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'); + exit(ExitCode.software.code); + } + } + createDiffProgress.complete(); + return patchArtifactBundles; + } + + @override + Future extractReleaseVersionFromArtifact(File artifact) async { + return shorebirdAndroidArtifacts.extractReleaseVersionFromAppBundle( + artifact.path, + ); + } +} 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 new file mode 100644 index 00000000..ffc37835 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patch_new/patch_new.dart @@ -0,0 +1,3 @@ +export 'android_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 new file mode 100644 index 00000000..7de5dd8b --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patch_new/patch_new_command.dart @@ -0,0 +1,330 @@ +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/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/commands/patch_new/android_patcher.dart'; +import 'package:shorebird_cli/src/commands/patch_new/patcher.dart'; +import 'package:shorebird_cli/src/commands/release_new/release_type.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.dart'; +import 'package:shorebird_cli/src/platform/platform.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_cli/src/version.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( + '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.', + ); + } + + 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['platform'] as List) + .map( + (platformArg) => ReleaseType.values.firstWhere( + (target) => target.cliName == platformArg, + ), + ) + .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(flavor: flavor, target: target); + case ReleaseType.ios: + throw UnimplementedError(); + case ReleaseType.iosFramework: + throw UnimplementedError(); + case ReleaseType.aar: + throw UnimplementedError(); + } + } + + 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(); + + 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 { + // 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, + ); + await confirmCreatePatch( + app: app, + releaseVersion: releaseVersion, + patcher: patcher, + patchArtifactBundles: patchArtifactBundles, + ); + await codePushClientWrapper.publishPatch( + appId: appId, + releaseId: release.id, + metadata: CreatePatchMetadata( + releasePlatform: patcher.releaseType.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: 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_new/patcher.dart b/packages/shorebird_cli/lib/src/commands/patch_new/patcher.dart new file mode 100644 index 00000000..b486d053 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patch_new/patcher.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/release_new/release_type.dart'; +import 'package:shorebird_cli/src/platform/platform.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; + +/// {@template patcher} +/// Platform-specific functionality to create a patch. +/// {@endtemplate} +abstract class Patcher { + /// {@macro patcher} + Patcher({required this.flavor, required this.target}); + + /// The flavor of the release, if any. + final String? flavor; + + /// The target script to run, if any. + final String? target; + + /// The type of artifact we are creating a release for. + ReleaseType get releaseType; + + /// Used to compare release and patch artifacts to determine if a patch can + /// be applied to a release. + ArchiveDiffer get archiveDiffer; + + /// The identifier used for the "primary" release artifact, usually a bundle. + /// For example, 'aab' for Android, 'xcarchive' for iOS. + String get primaryReleaseArtifactArch; + + /// The root directory of the current project. + Directory get projectRoot => shorebirdEnv.getShorebirdProjectRoot()!; + + /// Asserts that the command can be run. + Future assertPreconditions(); + + /// Asserts that the combination arguments passed to the command are valid. + Future assertArgsAreValid() async {} + + /// Builds the release artifacts for the given platform. Returns the "primary" + /// artifact for the platform (e.g. the AAB for Android, the IPA for iOS). + Future buildPatchArtifact(); + + /// Determines the release version from the provided app artifact. + Future extractReleaseVersionFromArtifact(File artifact); + + /// Creates the patch artifacts required to apply a patch to a release. + Future> createPatchArtifacts({ + required String appId, + required int releaseId, + }); +} diff --git a/packages/shorebird_cli/test/src/commands/patch_new/android_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch_new/android_patcher_test.dart new file mode 100644 index 00000000..2d91c727 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patch_new/android_patcher_test.dart @@ -0,0 +1,444 @@ +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_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/doctor.dart'; +import 'package:shorebird_cli/src/engine_config.dart'; +import 'package:shorebird_cli/src/logger.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/third_party/flutter_tools/lib/flutter_tools.dart'; +import 'package:shorebird_cli/src/validators/validators.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../matchers.dart'; +import '../../mocks.dart'; + +void main() { + group(AndroidPatcher, () { + late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + late Doctor doctor; + late Platform platform; + late Directory projectRoot; + late Logger logger; + late Progress progress; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdValidator shorebirdValidator; + late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts; + + late AndroidPatcher patcher; + + 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); + } + } + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + doctorRef.overrideWith(() => doctor), + engineConfigRef.overrideWith(() => const EngineConfig.empty()), + loggerRef.overrideWith(() => logger), + platformRef.overrideWith(() => platform), + processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + shorebirdAndroidArtifactsRef + .overrideWith(() => shorebirdAndroidArtifacts), + }, + ); + } + + setUpAll(() { + registerFallbackValue(Directory('')); + registerFallbackValue(ReleasePlatform.android); + registerFallbackValue(Uri.parse('https://example.com')); + setExitFunctionForTests(); + }); + + tearDownAll(restoreExitFunction); + + setUp(() { + artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + doctor = MockDoctor(); + platform = MockPlatform(); + progress = MockProgress(); + projectRoot = Directory.systemTemp.createTempSync(); + logger = MockLogger(); + flutterValidator = MockShorebirdFlutterValidator(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts(); + + when(() => logger.progress(any())).thenReturn(progress); + + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + + patcher = AndroidPatcher(flavor: null, target: null); + }); + + group('archiveDiffer', () { + test('is an AndroidArchiveDiffer', () { + expect(patcher.archiveDiffer, isA()); + }); + }); + + group('primaryReleaseArtifactArch', () { + test('is "aab"', () { + expect(patcher.primaryReleaseArtifactArch, equals('aab')); + }); + }); + + group('assertArgsAreValid', () { + test('does nothing', () async { + await expectLater(patcher.assertArgsAreValid(), completes); + }); + }); + + group('assertPreconditions', () { + setUp(() { + when(() => doctor.androidCommandValidators) + .thenReturn([flutterValidator]); + when(flutterValidator.validate).thenAnswer((_) async => []); + }); + + group('when validation succeeds', () { + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: + any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: + any(named: 'supportedOperatingSystems'), + ), + ).thenAnswer((_) async {}); + }); + + test('returns normally', () async { + await expectLater( + () => runWithOverrides(patcher.assertPreconditions), + returnsNormally, + ); + }); + }); + + group('when validation fails', () { + setUp(() { + final exception = ValidationFailedException(); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: + any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + final exception = ValidationFailedException(); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: + any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + ), + ).thenThrow(exception); + await expectLater( + () => runWithOverrides(patcher.assertPreconditions), + exitsWithCode(exception.exitCode), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: [flutterValidator], + ), + ).called(1); + }); + }); + }); + + group('buildPatchArtifact', () { + const flutterVersionAndRevision = '3.10.6 (83305b5088)'; + late File aabFile; + + setUp(() { + aabFile = File(''); + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + when( + () => artifactBuilder.buildAppBundle( + flavor: any(named: 'flavor'), + target: any(named: 'target'), + targetPlatforms: any(named: 'targetPlatforms'), + ), + ).thenAnswer((_) async => aabFile); + }); + + group('when build fails', () { + final exception = ArtifactBuildException('error'); + + setUp(() { + when( + () => artifactBuilder.buildAppBundle( + flavor: any(named: 'flavor'), + target: any(named: 'target'), + ), + ).thenThrow(exception); + when(() => logger.progress(any())).thenReturn(progress); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail('error')).called(1); + }); + }); + + group('when patch artifacts cannot be found', () { + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err('Cannot find patch build artifacts.'), + ).called(1); + verify( + () => logger.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''', + ), + ).called(1); + }); + }); + + group('when build succeeds', () { + setUp(setUpProjectRootArtifacts); + + test('returns the aab file', () async { + final result = await runWithOverrides(patcher.buildPatchArtifact); + expect(result, equals(aabFile)); + }); + }); + }); + + group('createPatchArtifacts', () { + const arch = 'aarch64'; + const releaseArtifact = ReleaseArtifact( + id: 0, + releaseId: 0, + arch: arch, + platform: ReleasePlatform.android, + hash: '#', + size: 42, + url: 'https://example.com', + ); + + setUp(() { + 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(() => artifactManager.downloadFile(any())) + .thenAnswer((_) async => File('')); + }); + + group('when release artifact fails to download', () { + setUp(() { + when( + () => artifactManager.downloadFile(any()), + ).thenThrow(Exception('error')); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: 'appId', + releaseId: 0, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail('Exception: error')).called(1); + }); + }); + + group('when unable to find patch build artifacts', () { + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: 'appId', + releaseId: 0, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify(() => logger.err('Could not find patch artifacts')).called(1); + }); + }); + + group('when unable to create diffs', () { + setUp(() { + setUpProjectRootArtifacts(); + + when( + () => artifactManager.createDiff( + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), + ), + ).thenThrow(Exception('error')); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: 'appId', + releaseId: 0, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail('Exception: error')).called(1); + }); + }); + + group('when patch artifacts successfully created', () { + setUp(() { + setUpProjectRootArtifacts(); + when( + () => artifactManager.createDiff( + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), + ), + ).thenAnswer((_) async { + final tempDir = Directory.systemTemp.createTempSync(); + final diffPath = p.join(tempDir.path, 'diff'); + File(diffPath) + ..createSync() + ..writeAsStringSync('test'); + return diffPath; + }); + }); + + test('returns patch artifact bundles', () async { + final result = await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: 'appId', + releaseId: 0, + ), + ); + + expect(result, hasLength(Arch.values.length)); + }); + }); + }); + + group('extractReleaseVersionFromArtifact', () { + setUp(() { + when( + () => shorebirdAndroidArtifacts.extractReleaseVersionFromAppBundle( + any(), + ), + ).thenAnswer((_) async => '1.0.0'); + }); + + test( + '''returns value of shorebirdAndroidArtifacts.extractReleaseVersionFromAppBundle''', + () async { + expect( + await runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact(File('')), + ), + equals('1.0.0'), + ); + }); + }); + + group('patchArtifactForDiffCheck', () { + late File aabFile; + setUp(() { + aabFile = File(''); + when( + () => shorebirdAndroidArtifacts.findAab( + project: any(named: 'project'), + flavor: any(named: 'flavor'), + ), + ).thenReturn(aabFile); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart b/packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart new file mode 100644 index 00000000..6b5a7a0f --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart @@ -0,0 +1,565 @@ +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +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_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/release_new/release_type.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/deployment_track.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/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.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'; +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +import '../../matchers.dart'; +import '../../mocks.dart'; + +void main() { + group('PatchNewCommand', () { + const appId = 'test-app-id'; + const appDisplayName = 'Test App'; + const arch = 'aarch64'; + const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; + const releasePlatform = ReleasePlatform.android; + const releaseVersion = '1.2.3+1'; + const shorebirdYaml = ShorebirdYaml(appId: appId); + + final appMetadata = AppMetadata( + appId: appId, + displayName: appDisplayName, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ); + final release = Release( + id: 0, + appId: appId, + version: releaseVersion, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + platformStatuses: {releasePlatform: ReleaseStatus.active}, + 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', + ); + + late ArchiveDiffer archiveDiffer; + late ArgResults argResults; + late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + late Logger logger; + late PatchDiffChecker patchDiffChecker; + late Patcher patcher; + late Progress progress; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + + late PatchNewCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + loggerRef.overrideWith(() => logger), + patchDiffCheckerRef.overrideWith(() => patchDiffChecker), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + }, + ); + } + + setUpAll(() { + registerFallbackValue(CreatePatchMetadata.forTest()); + registerFallbackValue(DeploymentTrack.production); + registerFallbackValue(Directory('')); + registerFallbackValue(File('')); + registerFallbackValue(FileSetDiff.empty()); + registerFallbackValue(ReleasePlatform.android); + registerFallbackValue(Uri.parse('https://example.com')); + setExitFunctionForTests(); + }); + + tearDownAll(restoreExitFunction); + + setUp(() { + archiveDiffer = MockAndroidArchiveDiffer(); + argResults = MockArgResults(); + artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockLogger(); + progress = MockProgress(); + patchDiffChecker = MockPatchDiffChecker(); + patcher = MockPatcher(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + + when(() => argResults['platform']).thenReturn(['android']); + when(() => argResults['release-version']).thenReturn(releaseVersion); + when(() => argResults.wasParsed(any())).thenReturn(true); + + when(() => artifactManager.downloadFile(any())) + .thenAnswer((_) async => File('')); + + 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.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( + () => 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(() => logger.confirm(any())).thenReturn(true); + when(() => logger.progress(any())).thenReturn(progress); + + 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(() => patcher.archiveDiffer).thenReturn(archiveDiffer); + when(() => patcher.assertArgsAreValid()).thenAnswer((_) async {}); + when(() => patcher.assertPreconditions()).thenAnswer((_) async {}); + when(() => patcher.extractReleaseVersionFromArtifact(any())) + .thenAnswer((_) async => releaseVersion); + when(() => patcher.buildPatchArtifact()) + .thenAnswer((_) async => File('')); + when(() => patcher.releaseType).thenReturn(ReleaseType.android); + when(() => patcher.primaryReleaseArtifactArch).thenReturn('aab'); + when( + () => patcher.createPatchArtifacts( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => { + Arch.arm32: const PatchArtifactBundle( + arch: 'arm32', + hash: '#', + size: 42, + path: '', + ), + }, + ); + + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision); + 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( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterRevision); + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), + ), + ).thenAnswer((_) async => {}); + + command = PatchNewCommand(resolvePatcher: (_) => patcher) + ..testArgResults = argResults; + }); + + test('has non-empty description', () { + 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( + command.getPatcher(ReleaseType.android), + isA(), + ); + expect( + () => command.getPatcher(ReleaseType.aar), + throwsA(isA()), + ); + expect( + () => command.getPatcher(ReleaseType.ios), + throwsA(isA()), + ); + expect( + () => command.getPatcher(ReleaseType.iosFramework), + throwsA(isA()), + ); + }); + }); + + group('when release version is specified', () { + setUp(() { + when(() => argResults['release-version']).thenReturn(releaseVersion); + }); + + test('executes commands in order, only builds app once', () async { + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.success.code)); + + verifyInOrder([ + () => patcher.assertPreconditions(), + () => patcher.assertArgsAreValid(), + () => codePushClientWrapper.getApp(appId: appId), + () => codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ), + () => codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: patcher.primaryReleaseArtifactArch, + platform: releasePlatform, + ), + () => patcher.buildPatchArtifact(), + () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( + allowAssetChanges: false, + allowNativeChanges: false, + archiveDiffer: archiveDiffer, + localArtifact: any(named: 'localArtifact'), + releaseArtifact: any(named: 'releaseArtifact'), + ), + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: release.id, + ), + () => logger.confirm('Would you like to continue?'), + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: release.id, + metadata: any(named: 'metadata'), + platform: releasePlatform, + patchArtifactBundles: any(named: 'patchArtifactBundles'), + track: DeploymentTrack.production, + ), + ]); + }); + }); + + group('when release version is not specified', () { + setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(false); + }); + + test( + 'executes commands in order, builds app to determine release version', + () async { + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.success.code)); + + verifyInOrder([ + () => patcher.assertPreconditions(), + () => patcher.assertArgsAreValid(), + () => codePushClientWrapper.getApp(appId: appId), + () => patcher.buildPatchArtifact(), + () => patcher.extractReleaseVersionFromArtifact(any()), + () => codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ), + () => codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: patcher.primaryReleaseArtifactArch, + platform: releasePlatform, + ), + () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( + allowAssetChanges: false, + allowNativeChanges: false, + archiveDiffer: archiveDiffer, + localArtifact: any(named: 'localArtifact'), + releaseArtifact: any(named: 'releaseArtifact'), + ), + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: release.id, + ), + () => logger.confirm('Would you like to continue?'), + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: release.id, + metadata: any(named: 'metadata'), + platform: releasePlatform, + patchArtifactBundles: any(named: 'patchArtifactBundles'), + track: DeploymentTrack.production, + ), + ]); + }); + + group('when release Flutter version is not default', () { + const releaseFlutterRevision = 'different-revision'; + + setUp(() { + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer( + (_) async => Release( + id: 0, + appId: appId, + version: releaseVersion, + flutterRevision: releaseFlutterRevision, + displayName: '1.2.3+1', + platformStatuses: {releasePlatform: ReleaseStatus.active}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ), + ); + }); + + test('builds app twice if release flutter version is not default', + () async { + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.success.code)); + + verifyInOrder([ + () => patcher.buildPatchArtifact(), + () => shorebirdEnv.copyWith( + flutterRevisionOverride: releaseFlutterRevision, + ), + () => patcher.buildPatchArtifact(), + ]); + }); + }); + }); + + group('when running on CI', () { + test('does not prompt for confirmation', () async { + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); + + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.success.code)); + + verifyNever(() => logger.confirm(any())); + }); + }); + + group('when user declines to continue', () { + setUp(() { + when(() => logger.confirm(any())).thenReturn(false); + }); + + test('exits with message and success code', () async { + await expectLater( + () => runWithOverrides(command.run), + exitsWithCode(ExitCode.success), + ); + verify(() => logger.info('Aborting.')).called(1); + }); + }); + + group('when the target release is in a draft state', () { + setUp(() { + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer( + (_) async => Release( + id: 0, + appId: appId, + version: releaseVersion, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + platformStatuses: {releasePlatform: ReleaseStatus.draft}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ), + ); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(command.run), + exitsWithCode(ExitCode.software), + ); + + verify( + () => 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.''', + ), + ).called(1); + }); + }); + + group('when primary release artifact fails to download', () { + final error = Exception('Failed to download primary release artifact.'); + + setUp(() { + when(() => artifactManager.downloadFile(any())).thenThrow(error); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(command.run), + exitsWithCode(ExitCode.software), + ); + + verify( + () => progress.fail( + 'Exception: Failed to download primary release artifact.', + ), + ).called(1); + }); + }); + + group('when unpatchable diffs exist', () { + group('when user cancels', () { + setUp(() { + when( + () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + archiveDiffer: archiveDiffer, + localArtifact: any(named: 'localArtifact'), + releaseArtifact: any(named: 'releaseArtifact'), + ), + ).thenThrow(UserCancelledException()); + }); + + test('exits with code 0', () async { + await expectLater( + () => runWithOverrides(command.run), + exitsWithCode(ExitCode.success), + ); + }); + }); + + group('when UnpatchableChangeException is thrown', () { + setUp(() { + when( + () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + archiveDiffer: archiveDiffer, + localArtifact: any(named: 'localArtifact'), + releaseArtifact: any(named: 'releaseArtifact'), + ), + ).thenThrow(UnpatchableChangeException()); + }); + + test('logs and exits with code 70', () async { + await expectLater( + () => runWithOverrides(command.run), + exitsWithCode(ExitCode.software), + ); + + verify(() => logger.info('Exiting.')).called(1); + }); + }); + }); + + group('when patching to the staging track', () { + setUp(() { + when(() => argResults['staging']).thenReturn(true); + }); + + test('publishes to the staging track', () async { + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.success.code)); + + verify( + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: release.id, + metadata: any(named: 'metadata'), + platform: releasePlatform, + patchArtifactBundles: any(named: 'patchArtifactBundles'), + track: DeploymentTrack.staging, + ), + ).called(1); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/release_new/release_new_command_test.dart b/packages/shorebird_cli/test/src/commands/release_new/release_new_command_test.dart index 51ed7bd9..e2eea157 100644 --- a/packages/shorebird_cli/test/src/commands/release_new/release_new_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release_new/release_new_command_test.dart @@ -171,7 +171,7 @@ void main() { ..testArgResults = argResults; }); - test('has description', () { + test('has non-empty description', () { expect(command.description, isNotEmpty); }); diff --git a/packages/shorebird_cli/test/src/mocks.dart b/packages/shorebird_cli/test/src/mocks.dart index 401363c1..9720635d 100644 --- a/packages/shorebird_cli/test/src/mocks.dart +++ b/packages/shorebird_cli/test/src/mocks.dart @@ -16,6 +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/release_new/releaser.dart'; import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/doctor.dart'; @@ -111,6 +112,8 @@ class MockOperatingSystemInterface extends Mock class MockPatchDiffChecker extends Mock implements PatchDiffChecker {} +class MockPatcher extends Mock implements Patcher {} + class MockPlatform extends Mock implements Platform {} class MockProcessResult extends Mock implements ShorebirdProcessResult {}